From 8853b027efd3bef1ab1d7b331f6c92218e0773ba Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 01:13:28 +0000 Subject: [PATCH] feat(decisioning)!: AdCP 3.3 seller-side rules Version literals: the capabilities signing gate parses bundle-precision versions (3.3.0-beta.1 read as pre-3.2), PrincipalService accepts 3.3 async payload shapes by default, and account key continuity reduces from the SDK's own release instead of 3.2. sales-fixed-rate rolls up to media_buy in SPECIALISM_TO_PROTOCOLS, so a seller claiming only that specialism advertises supported_protocols. Advertised measurement terms: get_products and list_products refuse a product whose measurement_window names no declared reporting_capabilities.measurement_windows[].window_id, and TermsRejectedError refuses to reject a buyer replaying advertised terms. Property domain identifiers: create/update_property_list reject malformed domain and subdomain identifiers with INVALID_REQUEST, the MCP list resolver rejects a snapshot carrying one instead of canonicalizing it, and normalize_property_list_identifiers converts values before submission. Counterparty-asserted domain lists: bound_counterparty_domains cuts publisher_domains / data_provider_domains to 100 entries, 253 characters and the 8 KiB / 16 KiB byte budgets, and the capabilities projection truncates an oversized list and sets *_complete: false. CreativeMergeRejectedError, RightsChannelViolationError and their details narrowing helpers join the typed errors. BREAKING CHANGE: create_property_list / update_property_list fail with INVALID_REQUEST on mixed-case, trailing-dot or URL-shaped domain identifiers, and MCPPropertyListFetcher fails a resolution carrying one. get_products / list_products fail with INTERNAL_ERROR when a product advertises an undeclared measurement_window. get_adcp_capabilities truncates oversized publisher_domains / data_provider_domains. PrincipalService accepts "3.3" async versions by default. Part of #1668 Co-Authored-By: Claude Opus 5.5 (1M context) --- MIGRATION_v9_to_v10.md | 4 + docs/adcp-3.3-sdk-inventory.md | 20 +- docs/decisioning-capabilities.md | 11 + docs/property-list-filtering.md | 30 + src/adcp/accounts.py | 5 +- src/adcp/decisioning/__init__.py | 24 + src/adcp/decisioning/domain_lists.py | 177 ++++++ src/adcp/decisioning/errors.py | 218 ++++++- src/adcp/decisioning/handler.py | 34 +- src/adcp/decisioning/measurement_terms.py | 119 ++++ src/adcp/decisioning/specialisms/sales.py | 12 +- src/adcp/property_lists.py | 190 +++++- src/adcp/server/principal.py | 6 +- tests/test_decisioning_33_seller_rules.py | 676 ++++++++++++++++++++++ tests/test_property_lists.py | 9 +- 15 files changed, 1514 insertions(+), 21 deletions(-) create mode 100644 src/adcp/decisioning/domain_lists.py create mode 100644 src/adcp/decisioning/measurement_terms.py create mode 100644 tests/test_decisioning_33_seller_rules.py diff --git a/MIGRATION_v9_to_v10.md b/MIGRATION_v9_to_v10.md index 28dcf3f19..cf38abec3 100644 --- a/MIGRATION_v9_to_v10.md +++ b/MIGRATION_v9_to_v10.md @@ -19,6 +19,10 @@ the SDK-relevant inventory is [docs/adcp-3.3-sdk-inventory.md](docs/adcp-3.3-sdk | `reporting_webhook.operation_id` is a generated field | The field, its constraints and its never-null marker come from the 3.3 schema. The SDK 9 runtime patch is gone; nothing changes on the wire. | | RFC 9421 signing warns by default | AdCP 3.3 deprecates the `adcp/request-signing/v1` and `adcp/webhook-signing/v1` profiles for Web Bot Auth. Constructing `SigningConfig` or `RequestSignatureVerification` without Web Bot Auth emits a `DeprecationWarning`; both profiles keep working through 3.x. | | `evaluator_id` is gone from evaluator specs | AdCP 3.3 removes the experimental `build_creative.evaluator.evaluator_id` source form. Use inline `exemplars` or `agent_url`; put the seller's evaluator in `creative_policy.accepted_verifiers[]`. | +| Property-list domain identifiers must be lowercase ASCII hostnames | AdCP 3.3 rejects `Example.com`, `example.com.` and URL-shaped `domain` / `subdomain` identifier values. `PlatformHandler.create_property_list` and `update_property_list` now fail such a request with `INVALID_REQUEST` (`field` names the value) before your platform runs, and `MCPPropertyListFetcher` fails a resolution whose pages carry one (`SERVICE_UNAVAILABLE`, cached snapshot kept) instead of canonicalizing it. Senders call `adcp.property_lists.normalize_property_list_identifiers(request)` before submitting; it lowercases, converts IDNs to A-labels, strips a trailing dot and raises `PropertyListIdentifierError` for anything else. | +| Advertised `measurement_window` must be a declared window | AdCP 3.3 requires `measurement_terms.billing_measurement.measurement_window` on a product to name a `reporting_capabilities.measurement_windows[].window_id`. `get_products` and `list_products` on `PlatformHandler` now fail with `INTERNAL_ERROR` when a returned product advertises an undeclared window; declare the window or drop it. When rejecting buyer terms, raise `TermsRejectedError(proposed_terms=..., advertised_terms=...)`: it refuses (`ValueError`) to reject a buyer replaying the advertised terms. | +| Oversized `publisher_domains` / `data_provider_domains` are truncated | AdCP 3.3 bounds these counterparty-asserted capability lists (100 unique entries, 253 characters each, 8,192 bytes per field, 16,384 combined). The decisioning `get_adcp_capabilities` projection truncates a list that exceeds them, sets `publisher_domains_complete` / `data_provider_domains_complete` to `false` and logs a warning. Declare the publishers you represent rather than mirroring `adagents.json`; `adcp.decisioning.bound_counterparty_domains` bounds a list and reports `complete`. | +| `PrincipalService` accepts `3.3` async payloads | The default `accepted_async_adcp_versions` is `("3.2", "3.3")` (was `("3.2",)`), since the SDK emits 3.3-line payloads. A seller pinned to 3.2 passes `accepted_async_adcp_versions=("3.2",)`. | | Schema defaults appear in dumps for new 3.3 fields | `GetProductsRequest.include_collections` (`false`) and `proposal_refinement.hold_extension` (`false`) carry schema defaults, so a dumped model declares them. Tests that compare whole dictionaries need the new keys; a 3.3 → 3.2 downgrade drops them with a recorded loss. | | 3.3 → 3.2 downgrades apply semantic rules | A buyer below 3.3 of a seller answering in 3.3 no longer gets `VERSION_UNSUPPORTED` for these responses: `by_spot` rows without `aired_at` (`scheduled` / `preempted`) and delivery rows without `impressions` are omitted (losses `row_omitted_not_aired`, `row_omitted_impressions_not_measured`), never sent with `impressions: 0`; `media_buy.features.catalog_ingestion` and a `price_breakdown` carrying an `operator_fee` adjustment are dropped; `age_determination_bases` moves to `ext.adcp.age_determination_bases`, and a 3.2 response upgraded to 3.3 reads it back into an absent top-level field. Requests the 3.2 contract cannot express fail with `ADCPVersionConversionError` (`UNSUPPORTED_FEATURE` on the client) before sending: a `reporting_webhook` / `artifact_webhook` without `authentication` (keep sending it while you buy from 3.2 sellers; the SDK never invents one) and a daypart with `start_time` / `end_time` (use `start_hour` / `end_hour`; the SDK never rounds). The downgrade's final check now validates formats as the SDK validators do: earlier betas let such a request through invalid when it carried `start_time: "asap"`. See [docs/cross-version-conversion.md](docs/cross-version-conversion.md#33--32-semantic-rules). | | Delivery rows may omit `impressions` | Every `by_*` delivery-metrics row makes `impressions` optional. Treat absence as "not measured", never as zero. | diff --git a/docs/adcp-3.3-sdk-inventory.md b/docs/adcp-3.3-sdk-inventory.md index 06b9ea18c..9c80edbbd 100644 --- a/docs/adcp-3.3-sdk-inventory.md +++ b/docs/adcp-3.3-sdk-inventory.md @@ -27,7 +27,7 @@ it adopted the bundle, and what is left to the codegen/wire-fidelity (2) and | Negotiation | `negotiate_version` matches prereleases exactly. The client's outgoing conversion treats the SDK's served prerelease as its line (`is_convertible_version`), so a 3.3-beta.0 buyer still negotiates down to a 3.1 or 3.2 seller. | done (1) | | Translation | `convert_payload` steps 3.3-beta.0 ↔ 3.2 ↔ 3.1 ↔ 3.0 through the bundled schemas; the 3.3 step uses the `3.3.0-beta.0` bundle. Downgrades go through `_require_target_valid`, so a converted payload the older schema refuses fails as `ADCPVersionConversionError` (#1621 / #1630). | done (1); per-field rules below are (3) | | Restricted servers | A server that lists `supported_versions` without `adcp_version` now pins to the newest listed release (`version_policy.default_adcp_version`), instead of refusing because the SDK default (3.3-beta.0) is outside the list. | done (1) | -| Handler gate | `PlatformHandler.get_adcp_capabilities` used `startswith("3.2")` for the proposal-manager projection; it is now "3.2 or later". Audit every other `"3.2"` literal (`accounts.py`, `dispatch.py`, `signing/*`, `server/principal.py accepted_async_adcp_versions`) for 3.3 inclusion. | (3): #1668 | +| Handler gate | `PlatformHandler.get_adcp_capabilities` used `startswith("3.2")` for the proposal-manager projection; it is now "3.2 or later". Audit of every other `"3.2"` literal: `dispatch.py` legacy-format gate, `accounts.py` key continuity, `server/principal.py` relationship gate and the `signing/*` profile map (`signing_profile_for_adcp_version` maps 3.3 to the 3.2 RFC 9421 profile) already include 3.3; the remaining `"3.2"` literals in `signing/*` and `governance.py` name the 3.2 signing / JWS profile, not a release floor. Fixed: the capabilities signing gate (`_is_adcp_32_or_newer`) now parses bundle precision (`3.3.0-beta.1` read as pre-3.2); `PrincipalService` accepts `3.3` async payload shapes by default (`("3.2", "3.3")`); account key continuity reduces from the SDK's own release rather than `3.2`. Open: `media_buy_actions._supports_change_term_id` treats `3.3-beta.N` (N < 9) as pre-3.2-beta.9. | done (3), #1668; `media_buy_actions` follow-up | | `adcp_version` on `list_products` responses | `ListProductsResponse1/2` gained `adcp_version`. | generated | | `search_brands` | `SearchBrandsRequest` gained `adcp_major_version`. | generated | @@ -62,7 +62,7 @@ it adopted the bundle, and what is left to the codegen/wire-fidelity (2) and | `history[].origin` | `get_media_buys` `HistoryItem.origin` (`seller_operator`, `seller_system`, `connected_platform`, `unknown`) | Seller `revision` advances on every committed change (DR-0031); buyers pass the latest revision and expect `CONFLICT`. | generated | | `creative_assignments_as_of`, `approval_status_as_of` | `get_media_buys` `Package`, `CreativeApproval`; `list_creatives` `AssignedPackage` | Timestamps. | generated | | `landing_page_url`, `trackers` on assignments | `core/creative-assignment.json`, `core/assignment-tracker.json`; `sync_creatives` `Assignment` / `AssignmentOperations1`; `list_creatives` `AssignedPackage`; `get_media_buys` `CreativeApproval`; capabilities `creative.supports_assignment_landing_page`, `creative.supports_assignment_trackers` | Per-assignment overrides. | generated | -| `sync_creatives` `merges[]`, `AssignmentOperations4` | `sync_creatives` response `Merge`; error `CREATIVE_MERGE_REJECTED` with `error-details/creative-merge-rejected.json`; enum `creative-merge-rejection-reason`; capability `creative.supports_assignment_merge` | Creative merge. | generated; error-details narrowing: (3), #1668 | +| `sync_creatives` `merges[]`, `AssignmentOperations4` | `sync_creatives` response `Merge`; error `CREATIVE_MERGE_REJECTED` with `error-details/creative-merge-rejected.json`; enum `creative-merge-rejection-reason`; capability `creative.supports_assignment_merge` | Creative merge. | generated; `CreativeMergeRejectedError` mints schema-valid details (field defaults to the first rejected `assignment_operations` entry) and `creative_merge_rejected_details` narrows them: done (3), #1668 | | `list_creatives` `platform_id`, `provenance`, `seller_revision`, `status_as_of`; request filter `provenance` | `creative/list-creatives-response.json`; `x-entity-types` `creative_seller_revision`; capability `creative.stable_creative_ids` | `platform_id` is native in 3.3 (adcp#8175). There was no forward-compat patch for it in this SDK; nothing to retire. | generated; `stable_creative_ids` seller support and `delete_missing` exclusion: (3), #1638 | | `validate_input` `Warning.field` | `creative/validate-input-result.json` | `field` is now declared as a string (#8212). | generated | | `build_creative` `errors` | `BuildCreativeResponse1/3` | Errors array on build responses. | generated | @@ -101,7 +101,7 @@ it adopted the bundle, and what is left to the codegen/wire-fidelity (2) and | Field | Where | SDK status | | --- | --- | --- | | `signal_status`, `source_audience_id`, `age_determination_bases` | `get_signals` request `Field1` and response `Signal`; `SignalDefinition`, `SignalDefinitionEnrichment`, `SignalListing`; enum `signal-status`; capability `signals.features.signal_lifecycle` | generated; interim `ext.adcp.age_determination_bases` for 3.2 peers (top-level wins): done (3), #1667 | -| `signals.data_provider_domains_complete`, `media_buy.portfolio.publisher_domains_complete` | capabilities; byte budgets 8,192 per field / 16,384 combined, 100 entries, 253 chars each; absence is never a denial | generated; bound enforcement in `property_lists` / portfolio readers: (3), #1668 | +| `signals.data_provider_domains_complete`, `media_buy.portfolio.publisher_domains_complete` | capabilities; byte budgets 8,192 per field / 16,384 combined, 100 entries, 253 chars each; absence is never a denial | `adcp.decisioning.bound_counterparty_domains` cuts a list to the bounds and reports `complete`; `get_adcp_capabilities` projection truncates an oversized declared list and sets `*_complete: false`. The SDK has no buyer-side reader that treats either list as an allowlist: done (3), #1668 | ### Accounts and identity @@ -119,7 +119,7 @@ it adopted the bundle, and what is left to the codegen/wire-fidelity (2) and | `error-details/unsupported-targeting-dimension.json` | under `UNSUPPORTED_FEATURE`; adapters minting `UNSUPPORTED_TARGETING_DIMENSION` / `DEPRECATED_TARGETING_DIMENSION` should migrate | | generated; decisioning error minting: (3), #1642 | | `error-details/agent-permission-denied.json` `Reason` | new nested enum | | generated | | `brand.json` fetch cap 2 MiB | all fetch paths; `MAX_JWKS_BYTES`, `MAX_CAPABILITIES_BYTES` unchanged | 3.2 verifiers may reject files between 256 KiB and 2 MiB. | done (3), #1654: 2 MiB, depth 32, 10 s total deadline, bounded concurrent fetches; follow-ups on #1635 | -| Property `domain` / `subdomain` lowercase ASCII hostnames | property schemas; invalid values → `INVALID_REQUEST` | | validation and sender normalizer: (3), #1668 | +| Property `domain` / `subdomain` lowercase ASCII hostnames | property schemas; invalid values → `INVALID_REQUEST` | | `PlatformHandler.create_property_list` / `update_property_list` reject invalid `domain` / `subdomain` identifiers with `INVALID_REQUEST` (`validate_property_list_identifiers`); `MCPPropertyListFetcher` rejects a snapshot carrying one instead of canonicalizing it; sender normalizer `adcp.property_lists.normalize_property_list_identifiers`: done (3), #1668 | | One agent-resolution rule | request signing, webhook signing, governance JWS `iss`, response signing, rights attestations; pinned `signing_keys` published in JWKS; RFC 7638 thumbprints; `key_origins`; `agent_url_match: canonical` | | `adcp.signing` audit: (3) | | RFC 9421 profile deprecation | `adcp/request-signing/v1`, `adcp/webhook-signing/v1` deprecated (removal no earlier than 4.0) in favour of Web Bot Auth | `rfc9421_deprecation_active()` keys on a 3.3 line in `COMPATIBLE_ADCP_VERSIONS`, so SDK 10 warns by default on legacy `SigningConfig` / `RequestSignatureVerification` construction. | done (1) | @@ -144,7 +144,7 @@ Every new declaration is optional; 3.2 behaviour stands when undeclared. | `reporting_capabilities` | `available_installment_statuses[]`; `spot_reporting.available_statuses[]`, `supports_break_position` | generated | | `supported_indicator_types` / `indicator_type` | `flight_extension_opportunity`, `scale_budget_opportunity` | generated | | `notification event types` | `collection_card_removed`, `collection_card_updated`, `collection_catalog_bulk_change`, `proposal_created`; wholesale feed `EntityType.collection`, `WholesaleFeedEvent10/11/12`, `Payload26/27/28` | generated | -| specialisms | `sales_fixed_rate` (new specialism for fixed-price, non-guaranteed sellers); `sales-broadcast-tv` 1.1.0 | generated; `adcp.decisioning.specialisms`: (3), #1668 | +| specialisms | `sales_fixed_rate` (new specialism for fixed-price, non-guaranteed sellers); `sales-broadcast-tv` 1.1.0 | generated; `sales-fixed-rate` has `REQUIRED_METHODS` / recommended methods / advertised tools and rolls up to `media_buy` in `SPECIALISM_TO_PROTOCOLS`. The packaged compliance assets carry no specialism storyboards, so `sales_fixed_rate` runs from the protocol's compliance tree: done (3), #1668 | | `canonical_creatives` | still not declared by 3.3 | the `_forward_compat` capability patch stays | ## 5. Error codes and error details @@ -152,16 +152,16 @@ Every new declaration is optional; 3.2 behaviour stands when undeclared. | Code / detail | Schema | Where it fires | SDK status | | --- | --- | --- | --- | | `REFERENCE_DEFINITION_CHANGED` | `error-details/reference-definition-changed.json` | `accept_proposal` when a pinned definition drifted; send only to 3.3 peers | generated; until then the 3.2 downgrade keeps it, since 3.2 `error.code` is open (#1667); minting and 3.2 downgrade mapping: (3), #1641 | -| `CREATIVE_MERGE_REJECTED` | `error-details/creative-merge-rejected.json` (`Rejection`, reason enum) | `sync_creatives` merges | generated; (3), #1668 | -| `RIGHTS_CHANNEL_VIOLATION` | `enums/error-code.json` | rights constraints `channels` / `excluded_channels` | generated; (3), #1668 | +| `CREATIVE_MERGE_REJECTED` | `error-details/creative-merge-rejected.json` (`Rejection`, reason enum) | `sync_creatives` merges | `CreativeMergeRejectedError`, `creative_merge_rejected_details`: done (3), #1668 | +| `RIGHTS_CHANNEL_VIOLATION` | `enums/error-code.json` | rights constraints `channels` / `excluded_channels` | `RightsChannelViolationError` (details `rights_id`, `channel`, `capability_id`; usable as an advisory `errors[]` entry via `to_wire()`), `rights_channel_violation_details`: done (3), #1668 | | `UNSUPPORTED_FEATURE` + `unsupported-targeting-dimension` | `error-details/unsupported-targeting-dimension.json` (`SupportedDimension`) | replaces adapter-minted `UNSUPPORTED_TARGETING_DIMENSION` / `DEPRECATED_TARGETING_DIMENSION` | generated; (3), #1642 | | `mutation_outcome` | `core/error.json` | every error (`not_applied`, `unknown`, `applied`) | generated; (3), #1642 | | `definition_changed` | `enums/proposal-refinement-reason.json` | `refine_proposals` `unable` with `drifted_pins[]` | generated | | `REVISION_UNREADABLE` with `reporting_revision_id` | 3.2.4 | already in SDK 9 | done | | `request_body_malformed` | A2A hardening (strict unsigned-body parsing, Part shape rules, task-continuation binding) becomes MUST | | done (3), #1659 | | `request_target_uri_malformed` | conformance vector `negative/026-non-ascii-host` re-coded | adopted with 3.2.4 (#1479) | done | -| `TERMS_REJECTED` | must not fire for replayed advertised measurement terms; windows reference `reporting_capabilities.measurement_windows[].window_id` | | decisioning: (3), #1668 | -| `COMPLIANCE_UNSATISFIED` | `build_creative` refinement that narrows inherited `required_disclosures` / `prohibited_claims` | | (3), #1668 | +| `TERMS_REJECTED` | must not fire for replayed advertised measurement terms; windows reference `reporting_capabilities.measurement_windows[].window_id` | | `get_products` / `list_products` refuse a product advertising an undeclared window (`INTERNAL_ERROR`, `check_advertised_measurement_terms`); `TermsRejectedError(proposed_terms=, advertised_terms=)` refuses to reject a replay (`is_advertised_terms_replay`): done (3), #1668 | +| `COMPLIANCE_UNSATISFIED` | `build_creative` refinement that narrows inherited `required_disclosures` / `prohibited_claims` | | not applicable: the SDK passes `build_creative` (including `refine_from_build_variant_id`) to the platform and retains no leaf compliance inputs, #1668 | | `ACCOUNT_NOT_FOUND` | discovery with an unresolved `account` | | done (#1288); storyboard confirmation: (3) | ## 6. Enum changes @@ -191,7 +191,7 @@ Every new declaration is optional; 3.2 behaviour stands when undeclared. | Item | Detail | SDK status | | --- | --- | --- | -| `evaluator_id` source form removed | `core/evaluator-spec.json`: `EvaluatorSpec3` gone, `EvaluatorSpec2` gains `agent_url` and loses `evaluator_id`; `list_evaluators` withdrawn; the seller's evaluator goes in `creative_policy.accepted_verifiers[]`. No alias. | generated; any SDK helper that minted `evaluator_id`: (3), #1668; migration note in `MIGRATION_v9_to_v10.md` | +| `evaluator_id` source form removed | `core/evaluator-spec.json`: `EvaluatorSpec3` gone, `EvaluatorSpec2` gains `agent_url` and loses `evaluator_id`; `list_evaluators` withdrawn; the seller's evaluator goes in `creative_policy.accepted_verifiers[]`. No alias. | generated; no SDK helper mints `evaluator_id` (only the 3.1 / 3.2 versioned views keep it): nothing to remove, #1668; migration note in `MIGRATION_v9_to_v10.md` | | `seller_id` on event source and audience results | deprecated for `platform_id`; sellers emit both with identical values | generated; seller dual emission and buyer preference: (3), #1639 | | Natural-key `AccountRef` after provisioning | deprecated; `account_id` with `account.stable_account_id` | (3), #1478 | | RFC 9421 signing profiles | deprecated for Web Bot Auth; verifiers keep accepting through 3.x | warning: done (1) | diff --git a/docs/decisioning-capabilities.md b/docs/decisioning-capabilities.md index 64a440a72..a9d394716 100644 --- a/docs/decisioning-capabilities.md +++ b/docs/decisioning-capabilities.md @@ -91,6 +91,17 @@ alongside the spec; they line up. - **`media_buy.features`** — `inline_creative_management`, `property_list_filtering`, `catalog_management` flags that gate buyer-side flow. +- **`media_buy.portfolio.publisher_domains`** — the publishers you actually + represent, not every domain whose `adagents.json` names you. AdCP 3.3 bounds + this counterparty-asserted list (and `signals.data_provider_domains`): at most + 100 unique entries of at most 253 characters, 8,192 bytes per field and + 16,384 bytes for both, measured as compact JSON including the member name. + `bound_counterparty_domains(domains)` cuts a list to those bounds in declared + order and reports `complete`; set `publisher_domains_complete=False` (or + `data_provider_domains_complete=False`) when it is not. The framework applies + the same bound to every `get_adcp_capabilities` response it projects: a list + that does not fit is truncated, its `*_complete` flag set to `false`, and a + warning logged. Buyers must never treat either list as an allowlist. ### When you support other protocols diff --git a/docs/property-list-filtering.md b/docs/property-list-filtering.md index b26f6747a..17ce22596 100644 --- a/docs/property-list-filtering.md +++ b/docs/property-list-filtering.md @@ -26,6 +26,10 @@ continuation cursors and mismatched list IDs. It retains identifier types, including app and CTV IDs. Connections use public-destination validation, IP-pinned delivery, disabled redirects, and disabled environment proxies. There is no assumed `/property-lists/{id}` or `/lists/{id}` REST route. +AdCP 3.3 makes `domain` and `subdomain` identifiers lowercase ASCII hostnames +(`core/identifier.json`); a fetched page carrying `Example.com`, `example.com.` +or a URL invalidates the whole snapshot, so the resolution fails and any cached +snapshot is kept, rather than the value being rewritten. Each resolver caches complete lists by URL, list ID, and credential digest. The earliest `cache_valid_until` across pages controls expiration; a missing @@ -65,6 +69,32 @@ handler, executor, registry = create_adcp_server_from_platform( ) ``` +# Domain identifiers + +AdCP 3.3 validates `domain` and `subdomain` identifier values as lowercase +ASCII hostnames (IDNs in A-label form), with no scheme, path, port, whitespace +or trailing dot; only `domain` permits a leading `*.`. An invalid value rejects +the containing request with `INVALID_REQUEST`; receivers never rewrite or drop +it. `PlatformHandler.create_property_list` and `update_property_list` enforce +this on `base_properties[].identifiers[]` and `filters.exclude_identifiers[]` +before the platform runs (`validate_property_list_identifiers`). + +Senders convert explicitly before submission: + +```python +from adcp.property_lists import normalize_property_list_identifiers + +request = normalize_property_list_identifiers(request) # Example.com. -> example.com +await client.create_property_list(request) +``` + +The normalizer lowercases, converts IDNs to A-labels, and strips surrounding +whitespace and a trailing dot, for identifiers and `base_properties[].publisher_domain`. +It raises `PropertyListIdentifierError` (with `field` and `reason`) for what it +cannot convert, including a pasted URL: extract the hostname yourself once you +have confirmed that is what you mean. `is_valid_domain_identifier(type, value)` +checks one value against the schema without rewriting it. + # Buyer checks Client checks are opt-in and work across MCP and A2A: diff --git a/src/adcp/accounts.py b/src/adcp/accounts.py index fa4ae76f1..59e84b969 100644 --- a/src/adcp/accounts.py +++ b/src/adcp/accounts.py @@ -370,13 +370,16 @@ async def observe( and isinstance(observed_version, str) and observed_version in {"3.0", "3.1"} ): + from adcp._version import resolve_adcp_version from adcp.compat.versions import convert_payload reduced = convert_payload( "sync_accounts", {"accounts": [key]}, direction="request", - source_version="3.2", + # The outgoing request was reduced from the SDK's own + # release (3.3-beta.1), not from 3.2. + source_version=resolve_adcp_version(None), target_version=observed_version, # Recomputes the key the outgoing request already reduced; # that conversion warned about these losses. diff --git a/src/adcp/decisioning/__init__.py b/src/adcp/decisioning/__init__.py index 2dfcae6b0..7071ae7d8 100644 --- a/src/adcp/decisioning/__init__.py +++ b/src/adcp/decisioning/__init__.py @@ -87,19 +87,33 @@ def create_media_buy( ) from adcp.decisioning.derive_packages import derive_packages_from_proposal from adcp.decisioning.dispatch import validate_platform +from adcp.decisioning.domain_lists import ( + BoundedDomainList, + bound_counterparty_domains, +) from adcp.decisioning.errors import ( AccountNotFoundError, AuthRequiredError, BillingNotPermittedForAgentError, + CreativeMergeRejectedError, MediaBuyNotFoundError, PermissionDeniedError, RateLimitedError, + RightsChannelViolationDetails, + RightsChannelViolationError, ServiceUnavailableError, + TermsRejectedError, UnsupportedFeatureError, ValidationError, + creative_merge_rejected_details, + rights_channel_violation_details, ) from adcp.decisioning.helpers import ref_account_id from adcp.decisioning.implementation_config import ProductConfigStore +from adcp.decisioning.measurement_terms import ( + check_advertised_measurement_terms, + is_advertised_terms_replay, +) from adcp.decisioning.media_buy_store import ( InMemoryMediaBuyContextStore, MediaBuyContexts, @@ -499,6 +513,16 @@ def __init__(self, *args: object, **kwargs: object) -> None: "TranslationMap", "UNKNOWN_UPDATE_ACTION", "UnsupportedFeatureError", + "TermsRejectedError", + "CreativeMergeRejectedError", + "RightsChannelViolationError", + "RightsChannelViolationDetails", + "creative_merge_rejected_details", + "rights_channel_violation_details", + "BoundedDomainList", + "bound_counterparty_domains", + "check_advertised_measurement_terms", + "is_advertised_terms_replay", "UpdateMediaBuyMutation", "UpstreamAuth", "UpstreamClientPool", diff --git a/src/adcp/decisioning/domain_lists.py b/src/adcp/decisioning/domain_lists.py new file mode 100644 index 000000000..0924da04f --- /dev/null +++ b/src/adcp/decisioning/domain_lists.py @@ -0,0 +1,177 @@ +"""Bound counterparty-asserted domain lists in capabilities (AdCP 3.3). + +``media_buy.portfolio.publisher_domains`` and ``signals.data_provider_domains`` +are populated from counterparties' ``/.well-known/adagents.json``, which any +domain owner publishes without the named agent's consent. AdCP 3.3 +(``docs/building/by-layer/L1/security.mdx`` §Response Size Bounds): "A single +counterparty-asserted field MUST NOT exceed one eighth of +``MAX_CAPABILITIES_BYTES`` (8,192 bytes), and all such fields together MUST NOT +exceed one quarter (16,384 bytes). Measure uncompressed serialized JSON — the +member name, the array punctuation, and the quoted elements". The structural +bounds in each field's ``x-adcp-validation`` are "at most 100 unique entries, +each at most 253 characters". Where the real set does not fit, an agent "MUST +truncate and mark the list incomplete (``publisher_domains_complete: false``, +``data_provider_domains_complete: false``) rather than exceed the budget, and +MUST NOT drop a field it does in fact populate or serve a truncated list as +complete." + +:func:`bound_counterparty_domains` is the capability-builder helper: it keeps +declaration order, drops duplicates and entries over 253 characters, then cuts +on the entry and byte bounds, and reports whether the result is complete. The +framework applies :func:`bound_capability_domain_lists` to every +``get_adcp_capabilities`` response it projects, so a declared list that does +not fit is truncated and flagged rather than served whole. + +Both lists are routing hints, never allowlists: a buyer MUST NOT treat a +domain's absence as evidence that the agent is unauthorized for it, complete +flag or not. The domain's own ``adagents.json`` is the authority. +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Iterable, MutableMapping +from dataclasses import dataclass +from typing import Any, Literal + +logger = logging.getLogger(__name__) + +__all__ = [ + "COUNTERPARTY_DOMAIN_LIST_BYTE_BUDGET", + "COUNTERPARTY_DOMAIN_LISTS_COMBINED_BYTE_BUDGET", + "MAX_COUNTERPARTY_DOMAIN_LENGTH", + "MAX_COUNTERPARTY_DOMAINS", + "BoundedDomainList", + "bound_capability_domain_lists", + "bound_counterparty_domains", +] + +#: Per-field budget: one eighth of ``MAX_CAPABILITIES_BYTES`` (64 KiB). +COUNTERPARTY_DOMAIN_LIST_BYTE_BUDGET = 8 * 1024 +#: Budget across every counterparty-asserted field: one quarter of 64 KiB. +COUNTERPARTY_DOMAIN_LISTS_COMBINED_BYTE_BUDGET = 16 * 1024 +#: ``x-adcp-validation.advisory_bounds.max_items``. +MAX_COUNTERPARTY_DOMAINS = 100 +#: ``x-adcp-validation.advisory_bounds.item_max_length``. +MAX_COUNTERPARTY_DOMAIN_LENGTH = 253 + +CounterpartyDomainField = Literal["publisher_domains", "data_provider_domains"] + + +@dataclass(frozen=True) +class BoundedDomainList: + """A counterparty-asserted list cut to the AdCP 3.3 bounds. + + ``complete`` is ``False`` when any declared entry was left out; set the + field's ``*_complete`` flag from it. ``serialized_bytes`` is the size of + ``"":[...]`` as compact JSON, the unit the budget is stated in. + """ + + field: str + domains: tuple[str, ...] + complete: bool + serialized_bytes: int + + +def _member_bytes(field: str, domains: Iterable[str]) -> int: + return len(json.dumps({field: list(domains)}, separators=(",", ":")).encode("utf-8")) - 2 + + +def bound_counterparty_domains( + domains: Iterable[str], + *, + field: CounterpartyDomainField = "publisher_domains", + byte_budget: int = COUNTERPARTY_DOMAIN_LIST_BYTE_BUDGET, + max_entries: int = MAX_COUNTERPARTY_DOMAINS, +) -> BoundedDomainList: + """Cut a ``publisher_domains`` / ``data_provider_domains`` list to the bounds. + + Keeps the declared order, so put the publishers or providers you most want + buyers to find first. Duplicates and entries longer than 253 characters are + dropped (and make the list incomplete); then entries are kept while the + count stays within ``max_entries`` and ``"":[...]`` stays within + ``byte_budget`` bytes. Raises :class:`ValueError` if no entry fits: the + spec forbids dropping a field the agent populates, so the caller must fix + the list rather than serve it empty. + """ + if field not in ("publisher_domains", "data_provider_domains"): + raise ValueError(f"{field!r} is not a counterparty-asserted domain list") + declared = list(domains) + kept: list[str] = [] + seen: set[str] = set() + # ``"":[]`` and one comma per entry after the first. + size = _member_bytes(field, ()) + for domain in declared: + if domain in seen or len(domain) > MAX_COUNTERPARTY_DOMAIN_LENGTH: + continue + entry = len(json.dumps(domain).encode("utf-8")) + (1 if kept else 0) + if len(kept) >= max_entries or size + entry > byte_budget: + break + seen.add(domain) + kept.append(domain) + size += entry + if declared and not kept: + raise ValueError(f"no {field} entry fits the AdCP counterparty-asserted list bounds") + return BoundedDomainList( + field=field, + domains=tuple(kept), + # Dropping a duplicate leaves the set whole; anything else does not. + complete=len(kept) == len(set(declared)), + serialized_bytes=size, + ) + + +def bound_capability_domain_lists( + response: MutableMapping[str, Any], + *, + combined_byte_budget: int = COUNTERPARTY_DOMAIN_LISTS_COMBINED_BYTE_BUDGET, +) -> bool: + """Bound both counterparty-asserted lists of a capabilities response in place. + + ``response`` is a ``get_adcp_capabilities`` wire dict. A list that exceeds + a bound is truncated and its ``*_complete`` flag set to ``False``; a list + that fits is left exactly as declared (including an explicit + ``*_complete: false``). The per-field budget applies to each list and the + combined budget to both together, ``publisher_domains`` first. Returns + whether anything was truncated. + """ + remaining = combined_byte_budget + truncated = False + locations: tuple[tuple[tuple[str, ...], CounterpartyDomainField], ...] = ( + (("media_buy", "portfolio"), "publisher_domains"), + (("signals",), "data_provider_domains"), + ) + for path, field in locations: + parent: Any = response + for key in path: + parent = parent.get(key) if isinstance(parent, MutableMapping) else None + if not isinstance(parent, MutableMapping): + continue + domains = parent.get(field) + if not isinstance(domains, list) or not all(isinstance(d, str) for d in domains): + continue + bounded = bound_counterparty_domains( + domains, + field=field, + byte_budget=min(COUNTERPARTY_DOMAIN_LIST_BYTE_BUDGET, remaining), + ) + remaining -= bounded.serialized_bytes + if bounded.complete: + continue + truncated = True + parent[field] = list(bounded.domains) + parent[f"{field}_complete"] = False + logger.warning( + "[adcp.decisioning] get_adcp_capabilities %s truncated from %d to %d " + "entries (%d bytes) to stay within the AdCP 3.3 counterparty-asserted " + "list bounds; %s_complete is false. Declare the publishers or providers " + "you represent rather than mirroring adagents.json, or bound the list " + "with adcp.decisioning.bound_counterparty_domains.", + field, + len(domains), + len(bounded.domains), + bounded.serialized_bytes, + field, + ) + return truncated diff --git a/src/adcp/decisioning/errors.py b/src/adcp/decisioning/errors.py index 82dea38f7..3fe1b89ae 100644 --- a/src/adcp/decisioning/errors.py +++ b/src/adcp/decisioning/errors.py @@ -19,20 +19,31 @@ from __future__ import annotations -from typing import Any, Literal +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal from adcp.decisioning.types import AdcpError +if TYPE_CHECKING: + from adcp.types.error_details import CreativeMergeRejectedDetails, Rejection + __all__ = [ "AccountNotFoundError", "AuthRequiredError", "BillingNotPermittedForAgentError", + "CreativeMergeRejectedError", "MediaBuyNotFoundError", "PermissionDeniedError", "RateLimitedError", + "RightsChannelViolationDetails", + "RightsChannelViolationError", "ServiceUnavailableError", + "TermsRejectedError", "UnsupportedFeatureError", "ValidationError", + "creative_merge_rejected_details", + "rights_channel_violation_details", ] @@ -319,3 +330,208 @@ def __init__( field=field, details=dict(details) or None, ) + + +class TermsRejectedError(AdcpError): + """Spec ``TERMS_REJECTED`` (``recovery='correctable'``). + + Raised from ``create_media_buy`` when the seller rejects buyer-proposed + ``measurement_terms``. The details SHOULD name the rejected term and the + seller's acceptable range or supported vendors. + + AdCP 3.3 (``core/measurement-terms.json``): "A seller MUST NOT advertise a + window (or other term value) in a product's measurement_terms that it would + reject with TERMS_REJECTED when a buyer replays those terms unchanged on + create_media_buy." Pass the package's ``proposed_terms`` and the product's + ``advertised_terms`` and the constructor raises :class:`ValueError` instead + of minting a rejection of a replay (see + :func:`adcp.decisioning.measurement_terms.is_advertised_terms_replay`): + accept the terms, or stop advertising them. + + :param term: The rejected term (``"billing_measurement"``, + ``"makegood_policy"``, ...), echoed as ``error.details.term``. + """ + + def __init__( + self, + *, + proposed_terms: Any = None, + advertised_terms: Any = None, + term: str | None = None, + message: str | None = None, + field: str | None = None, + suggestion: str | None = None, + **details: Any, + ) -> None: + if proposed_terms is not None and advertised_terms is not None: + from adcp.decisioning.measurement_terms import is_advertised_terms_replay + + if is_advertised_terms_replay(proposed_terms, advertised_terms): + raise ValueError( + "TERMS_REJECTED must not reject measurement_terms that replay the " + "product's advertised terms unchanged (AdCP 3.3). Accept them, or " + "remove them from the product's measurement_terms." + ) + merged: dict[str, Any] = dict(details) + if term is not None: + merged["term"] = term + super().__init__( + "TERMS_REJECTED", + message=message or "Proposed measurement terms were rejected.", + recovery="correctable", + field=field, + suggestion=suggestion, + details=merged or None, + ) + + +class CreativeMergeRejectedError(AdcpError): + """Spec ``CREATIVE_MERGE_REJECTED`` (``recovery='correctable'``, AdCP 3.3). + + Raised from ``sync_creatives`` when merge assignment operations fail + seller verification. Per ``enums/error-code.json`` the seller evaluates + every merge in the request, leaves every assignment unchanged (assignment + operations are atomic), SHOULD set ``error.field`` to the first rejected + ``assignment_operations`` entry, and SHOULD return + ``error-details/creative-merge-rejected.json`` listing each rejection. + + ``rejections`` are validated against the generated + ``CreativeMergeRejectedDetails`` model; ``field`` defaults to + ``assignment_operations[]``. A seller that does not + declare ``creative.supports_assignment_merge`` raises + :class:`UnsupportedFeatureError` instead. + + :param rejections: One entry per rejected merge: ``operation_index``, + ``creative_id``, ``package_id``, ``merged_creative_id`` and ``reason`` + (``content_mismatch``, ``format_mismatch``, ``not_on_package``, + ``not_supported``), as mappings or ``Rejection`` models. + """ + + def __init__( + self, + *, + rejections: Sequence[Rejection | Mapping[str, Any]], + message: str | None = None, + field: str | None = None, + suggestion: str | None = None, + ) -> None: + from adcp.types.error_details import CreativeMergeRejectedDetails + + parsed = CreativeMergeRejectedDetails.model_validate( + { + "rejections": [ + item.model_dump(mode="json") if hasattr(item, "model_dump") else dict(item) + for item in rejections + ] + } + ) + first = min(int(item.operation_index) for item in parsed.rejections) + super().__init__( + "CREATIVE_MERGE_REJECTED", + message=message or "One or more creative merge operations were rejected.", + recovery="correctable", + field=field if field is not None else f"assignment_operations[{first}]", + suggestion=suggestion, + details=parsed.model_dump(mode="json"), + ) + + +class RightsChannelViolationError(AdcpError): + """Spec ``RIGHTS_CHANNEL_VIOLATION`` (``recovery='correctable'``, AdCP 3.3). + + A creative's ``rights[]`` ``channels`` / ``excluded_channels`` restriction + does not permit the media channel of the requested output. The code is + advisory in 3.3: a creative agent MAY raise it to decline the output, or + attach :meth:`to_wire` as a non-terminal entry in ``errors[]`` on an + otherwise successful build. ``error.field`` SHOULD point at the offending + rights entry (``creative_manifest.rights[0].excluded_channels``) and the + details SHOULD include ``rights_id``, the requested ``channel`` and the + ``capability_id`` when the build named one. There is no error-details + schema for this code; :func:`rights_channel_violation_details` reads these + keys back. + """ + + def __init__( + self, + *, + rights_id: str, + channel: str, + capability_id: str | None = None, + message: str | None = None, + field: str | None = None, + suggestion: str | None = None, + **details: Any, + ) -> None: + merged: dict[str, Any] = {**details, "rights_id": rights_id, "channel": channel} + if capability_id is not None: + merged["capability_id"] = capability_id + super().__init__( + "RIGHTS_CHANNEL_VIOLATION", + message=message + or f"Rights grant {rights_id!r} does not permit the {channel!r} channel.", + recovery="correctable", + field=field, + suggestion=suggestion, + details=merged, + ) + + +@dataclass(frozen=True) +class RightsChannelViolationDetails: + """The documented ``RIGHTS_CHANNEL_VIOLATION`` detail keys, when present.""" + + rights_id: str | None + channel: str | None + capability_id: str | None + + +def _code_and_details(error: Any) -> tuple[Any, Any]: + if isinstance(error, Mapping): + return error.get("code"), error.get("details") + return getattr(error, "code", None), getattr(error, "details", None) + + +def creative_merge_rejected_details(error: Any) -> CreativeMergeRejectedDetails | None: + """Narrow a ``CREATIVE_MERGE_REJECTED`` error's details to the typed model. + + Accepts an :class:`AdcpError`, a generated ``Error`` or a wire mapping. + Returns ``None`` for any other code, or when the details are absent or do + not conform to ``error-details/creative-merge-rejected.json`` (the schema + makes them a SHOULD, so a buyer falls back to ``error.field``). + """ + from pydantic import ValidationError as PydanticValidationError + + from adcp.types.error_details import CreativeMergeRejectedDetails + + code, details = _code_and_details(error) + if code != "CREATIVE_MERGE_REJECTED" or details is None: + return None + try: + return CreativeMergeRejectedDetails.model_validate(details) + except PydanticValidationError: + return None + + +def rights_channel_violation_details(error: Any) -> RightsChannelViolationDetails | None: + """Read ``rights_id``, ``channel`` and ``capability_id`` off the error. + + Returns ``None`` unless the code is ``RIGHTS_CHANNEL_VIOLATION``. Each key + is a SHOULD, so any of them may be ``None``; non-string values read as + ``None``. + """ + code, details = _code_and_details(error) + if code != "RIGHTS_CHANNEL_VIOLATION": + return None + if hasattr(details, "model_dump"): + details = details.model_dump(mode="json") + values = details if isinstance(details, Mapping) else {} + + def _text(key: str) -> str | None: + value = values.get(key) + return value if isinstance(value, str) else None + + return RightsChannelViolationDetails( + rights_id=_text("rights_id"), + channel=_text("channel"), + capability_id=_text("capability_id"), + ) diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index 715164148..6f734d9a3 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -40,6 +40,7 @@ from adcp._version import ( is_adcp_version_at_least, + normalize_to_release_precision, resolve_adcp_version_alias, ) from adcp.decisioning._get_products_helpers import _project_product_fields @@ -64,7 +65,9 @@ _internal_error_message, _invoke_platform_method, ) +from adcp.decisioning.domain_lists import bound_capability_domain_lists from adcp.decisioning.implementation_config import ProductConfigStore +from adcp.decisioning.measurement_terms import check_advertised_measurement_terms from adcp.decisioning.negotiation import ( preflight_refinement_batch_or_raise, snapshot_refinement_sources, @@ -114,6 +117,7 @@ maybe_emit_sync_completion, task_webhook_owner_ready, ) +from adcp.property_lists import validate_property_list_identifiers from adcp.server.base import ADCPHandler, NotImplementedResponse, ToolContext logger = logging.getLogger(__name__) @@ -299,12 +303,24 @@ class _PublicAccount(_DecisioningAccount[Any]): _AUTH_INFO_METADATA_KEY = "adcp.auth_info" +def _response_products(response: Any) -> Any: + """Read ``products`` from a typed or dict discovery response.""" + if isinstance(response, Mapping): + return response.get("products") + return getattr(response, "products", None) + + def _is_adcp_32_or_newer(version: str | None) -> bool: - """Treat an unnegotiated response as the SDK's current 3.2 release.""" + """Whether a response is served at release 3.2 or later (3.3 included). + + An unnegotiated response is the SDK's own release, which is 3.2 or later. + Bundle precision (``3.3.0-beta.1``) and wire precision (``3.3-beta.1``) + compare the same; a prerelease counts as its line. + """ if version is None: return True try: - release = version.split("-", 1)[0] + release = normalize_to_release_precision(version).split("-", 1)[0] major_raw, minor_raw = release.split(".", 1) major, minor = int(major_raw), int(minor_raw) except (AttributeError, TypeError, ValueError): @@ -571,6 +587,7 @@ def _is_adcp_32_or_newer(version: str | None) -> bool: "sales-non-guaranteed": frozenset({"media_buy"}), "sales-guaranteed": frozenset({"media_buy"}), "sales-broadcast-tv": frozenset({"media_buy"}), + "sales-fixed-rate": frozenset({"media_buy"}), "sales-dooh": frozenset({"media_buy"}), "sales-social": frozenset({"media_buy"}), "sales-exchange": frozenset({"media_buy"}), @@ -2242,6 +2259,11 @@ async def get_adcp_capabilities( adcp_version=(context.resolved_adcp_version if context is not None else None), ) + # AdCP 3.3: counterparty-asserted domain lists (portfolio + # publisher_domains, signals data_provider_domains) are bounded; + # truncate and flag *_complete: false rather than serve them whole. + bound_capability_domain_lists(response) + from adcp.reporting.outbox.status_support import validate_status_claims from adcp.reporting.outbox.support import validate_activity_claims @@ -2306,6 +2328,7 @@ async def list_products( # type: ignore[override] filter_mode=self._property_list_filter_mode, ) result = await self._invoke_compact_lifecycle("list_products", platform_params, context) + check_advertised_measurement_terms(_response_products(result)) result = await maybe_apply_property_list_filter( params=params, response=result, @@ -2725,6 +2748,9 @@ async def _persist_draft_hook(get_products_result: Any) -> None: if isinstance(result, dict) and result.get("status") == "submitted": return cast("GetProductsResponse", result) response = cast("GetProductsResponse", result) + # AdCP 3.3: an advertised measurement_window must name a declared + # reporting_capabilities.measurement_windows[].window_id. + check_advertised_measurement_terms(_response_products(response)) # Post-adapter: capability-gated property-list filter. response = cast( "GetProductsResponse", @@ -4169,6 +4195,8 @@ async def create_property_list( # type: ignore[override] params: CreatePropertyListRequest, context: ToolContext | None = None, ) -> CreatePropertyListResponse: + # AdCP 3.3: malformed domain identifiers reject the whole request. + validate_property_list_identifiers(params) tool_ctx = context or ToolContext() account = await self._resolve_account( getattr(params, "account", None), tool_ctx, tool_name="create_property_list" @@ -4191,6 +4219,8 @@ async def update_property_list( # type: ignore[override] params: UpdatePropertyListRequest, context: ToolContext | None = None, ) -> UpdatePropertyListResponse: + # AdCP 3.3: malformed domain identifiers reject the whole request. + validate_property_list_identifiers(params) tool_ctx = context or ToolContext() account = await self._resolve_account( getattr(params, "account", None), tool_ctx, tool_name="update_property_list" diff --git a/src/adcp/decisioning/measurement_terms.py b/src/adcp/decisioning/measurement_terms.py new file mode 100644 index 000000000..989b193e0 --- /dev/null +++ b/src/adcp/decisioning/measurement_terms.py @@ -0,0 +1,119 @@ +"""Advertise only the measurement terms the seller accepts (AdCP 3.3). + +``core/measurement-terms.json`` ``billing_measurement.measurement_window``: +"When present, MUST reference a window_id declared in the product's +reporting_capabilities.measurement_windows. A seller MUST NOT advertise a +window (or other term value) in a product's measurement_terms that it would +reject with TERMS_REJECTED when a buyer replays those terms unchanged on +create_media_buy." + +Two halves: + +* :func:`check_advertised_measurement_terms` checks advertised products. The + framework runs it on synchronous ``get_products`` and ``list_products`` + responses, so a product whose ``measurement_window`` names an undeclared + window never reaches a buyer. +* :func:`is_advertised_terms_replay` tells a seller whether a buyer's proposed + ``measurement_terms`` replay what the product advertised. + :class:`~adcp.decisioning.errors.TermsRejectedError` refuses to mint + ``TERMS_REJECTED`` for such a replay when given the advertised terms. + +The framework does not look products up at ``create_media_buy``, so the replay +rule stays with the seller's code; pass ``advertised_terms`` to +``TermsRejectedError`` to have it enforced. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import Any + +from adcp.decisioning.types import AdcpError + +__all__ = [ + "check_advertised_measurement_terms", + "is_advertised_terms_replay", + "undeclared_measurement_window", +] + + +def _wire(value: Any) -> Any: + """Return the JSON form of a model or mapping (``None`` members dropped).""" + if hasattr(value, "model_dump"): + return value.model_dump(mode="json", exclude_none=True) + if isinstance(value, Mapping): + return {key: _wire(item) for key, item in value.items() if item is not None} + if isinstance(value, (list, tuple)): + return [_wire(item) for item in value] + return value + + +def _member(value: Any, name: str) -> Any: + """Read one member off a generated model or a wire mapping.""" + if isinstance(value, Mapping): + return value.get(name) + return getattr(value, name, None) + + +def undeclared_measurement_window(product: Any) -> str | None: + """Return the advertised ``measurement_window`` a product does not declare. + + ``None`` when the product advertises no window or the window is one of its + ``reporting_capabilities.measurement_windows[].window_id`` values. Accepts a + generated ``Product`` / ``CanonicalProduct`` or its wire mapping, and reads + only those members (no model dump), so it is cheap on every discovery call. + """ + billing = _member(_member(product, "measurement_terms"), "billing_measurement") + window = _member(billing, "measurement_window") + if not isinstance(window, str): + return None + declared = _member(_member(product, "reporting_capabilities"), "measurement_windows") + window_ids = {_member(entry, "window_id") for entry in declared or ()} + return None if window in window_ids else window + + +def check_advertised_measurement_terms(products: Iterable[Any] | None) -> None: + """Refuse advertised products whose ``measurement_window`` is undeclared. + + Raises ``AdcpError("INTERNAL_ERROR")`` naming the first offending product: + the seller's catalog is misconfigured, and a buyer replaying the advertised + terms would otherwise be rejected (or reconciled against a window the + product never defined). Declare the window under the product's + ``reporting_capabilities.measurement_windows``, or stop advertising it. + """ + for index, product in enumerate(products or ()): + window = undeclared_measurement_window(product) + if window is None: + continue + product_id = _member(product, "product_id") + raise AdcpError( + "INTERNAL_ERROR", + message=( + f"Product {product_id!r} advertises measurement_terms." + f"billing_measurement.measurement_window {window!r}, which is not a " + "window_id in its reporting_capabilities.measurement_windows. AdCP 3.3 " + "requires an advertised window to reference a declared window_id." + ), + recovery="terminal", + field=f"products[{index}].measurement_terms.billing_measurement.measurement_window", + ) + + +def is_advertised_terms_replay(proposed: Any, advertised: Any) -> bool: + """Whether buyer-proposed ``measurement_terms`` replay the advertised terms. + + A replay proposes at least one term (``billing_measurement``, + ``makegood_policy``, or an extension member) and every term it proposes + equals the product's advertised value for that term, compared in wire form. + Changing any value inside a term (dropping the ``measurement_window``, + another vendor, a different remedy list) is a new proposal, not a replay. + """ + proposed_wire, advertised_wire = _wire(proposed), _wire(advertised) + if not isinstance(proposed_wire, Mapping) or not isinstance(advertised_wire, Mapping): + return False + if not proposed_wire: + return False + return all( + key in advertised_wire and advertised_wire[key] == value + for key, value in proposed_wire.items() + ) diff --git a/src/adcp/decisioning/specialisms/sales.py b/src/adcp/decisioning/specialisms/sales.py index 378368edc..562a453fa 100644 --- a/src/adcp/decisioning/specialisms/sales.py +++ b/src/adcp/decisioning/specialisms/sales.py @@ -2,8 +2,16 @@ A platform claiming any of the spec ``sales-*`` slugs (``sales-non-guaranteed``, ``sales-guaranteed``, ``sales-broadcast-tv``, -``sales-dooh``, ``sales-social``, ``sales-proposal-mode``, ``sales-catalog-driven``) -implements the methods on this Protocol. The slugs mirror +``sales-fixed-rate``, ``sales-dooh``, ``sales-social``, ``sales-proposal-mode``, +``sales-catalog-driven``) implements the methods on this Protocol. + +``sales-fixed-rate`` (AdCP 3.3) is a fixed-price, non-guaranteed seller in any +medium: its pricing options carry ``fixed_price`` and no bid is ever sent. Such +a seller MAY hold a buy for review: ``create_media_buy`` returns synchronous +success with ``confirmed_at: None``, and the provisional buy reports +``pending_creatives`` or ``pending_start``, never ``active``, until the seller +commits. The ``sales_fixed_rate`` storyboard grades the hold phases only when +the seller advertises the ``force_media_buy_confirmation`` controller scenario. The slugs mirror ``schemas/cache/enums/specialism.json``. The unified hybrid shape collapses 14 method names from v1's dual-method design (``createMediaBuy`` + ``createMediaBuyTask``) into 7: each mutating diff --git a/src/adcp/property_lists.py b/src/adcp/property_lists.py index b35b8a0d3..7c5844ee8 100644 --- a/src/adcp/property_lists.py +++ b/src/adcp/property_lists.py @@ -5,19 +5,193 @@ import hashlib import json import logging +import re from collections import OrderedDict -from collections.abc import Callable, Mapping +from collections.abc import Callable, Iterator, Mapping +from copy import deepcopy from dataclasses import dataclass from datetime import datetime, timezone from functools import lru_cache -from typing import Any, Literal +from typing import Any, Literal, TypeVar, cast from pydantic import RootModel -from adcp.identifiers import NormalizedIdentifier, normalize_identifier +from adcp.identifiers import ( + IdentifierValidationError, + NormalizedIdentifier, + normalize_domain, + normalize_identifier, +) logger = logging.getLogger(__name__) +_Request = TypeVar("_Request") + +# ``core/identifier.json`` (AdCP 3.3): "Domain and subdomain values MUST be +# lowercase ASCII hostnames (IDNs use lowercase A-label/punycode form), without +# scheme, path, port, whitespace, or trailing dot. Domain permits one leading +# '*.'; subdomain does not. [...] Invalid values MUST cause rejection of the +# containing request; receivers MUST NOT silently discard identifiers." The +# patterns and length limits below are the schema's. +_HOSTNAME = r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+" +_DOMAIN_IDENTIFIER_RE = re.compile(rf"(?:\*\.)?{_HOSTNAME}") +_SUBDOMAIN_IDENTIFIER_RE = re.compile(_HOSTNAME) + + +def is_valid_domain_identifier(identifier_type: str, value: Any) -> bool: + """Whether a ``domain`` / ``subdomain`` identifier value has the 3.3 format. + + Checks exactly what ``core/identifier.json`` requires; no value is + rewritten. Identifier types other than ``domain`` and ``subdomain`` are not + constrained by this rule and return ``True``. + """ + if identifier_type not in {"domain", "subdomain"}: + return True + if not isinstance(value, str): + return False + if identifier_type == "subdomain": + return len(value) <= 253 and _SUBDOMAIN_IDENTIFIER_RE.fullmatch(value) is not None + limit = 255 if value.startswith("*.") else 253 + return len(value) <= limit and _DOMAIN_IDENTIFIER_RE.fullmatch(value) is not None + + +class PropertyListIdentifierError(ValueError): + """A property-list domain identifier cannot be converted for submission. + + ``field`` is the request path (``base_properties[0].identifiers[2].value``) + and ``reason`` the :class:`~adcp.identifiers.IdentifierValidationError` + reason. The message excludes the supplied value. + """ + + def __init__(self, field: str, identifier_type: str, reason: str): + self.field = field + self.identifier_type = identifier_type + self.reason = reason + super().__init__(f"Invalid {identifier_type} identifier at {field}: {reason}") + + +def _wire_request(request: Any) -> dict[str, Any]: + if hasattr(request, "model_dump"): + return dict(request.model_dump(mode="json", exclude_none=True)) + if isinstance(request, Mapping): + return deepcopy(dict(request)) + raise TypeError("expected a property-list request model or mapping") + + +def _identifier_members(request: Any) -> dict[str, Any]: + """Dump only the members that carry identifiers (``base_properties``, ``filters``). + + The receiver check reads these alone, so it never serializes the rest of + the request (for example a buyer's ``push_notification_config``). + """ + + def dump(value: Any) -> Any: + if hasattr(value, "model_dump"): + return value.model_dump(mode="json", exclude_none=True) + if isinstance(value, (list, tuple)): + return [dump(item) for item in value] + return value + + return { + name: dump(field(request, name)) + for name in ("base_properties", "filters") + if field(request, name) is not None + } + + +def _identifier_slots(wire: Mapping[str, Any]) -> Iterator[tuple[str, dict[str, Any]]]: + """Yield ``(path, identifier)`` for every identifier object in the request. + + Covers direct-identifier ``base_properties[]`` sources and + ``filters.exclude_identifiers[]``, the identifier slots of + ``create_property_list`` / ``update_property_list``. + """ + for index, source in enumerate(wire.get("base_properties") or ()): + if isinstance(source, dict) and source.get("selection_type") == "identifiers": + for position, item in enumerate(source.get("identifiers") or ()): + if isinstance(item, dict): + yield f"base_properties[{index}].identifiers[{position}]", item + filters = wire.get("filters") + if isinstance(filters, dict): + for position, item in enumerate(filters.get("exclude_identifiers") or ()): + if isinstance(item, dict): + yield f"filters.exclude_identifiers[{position}]", item + + +def normalize_property_list_identifiers(request: _Request) -> _Request: + """Explicitly convert a property-list request's domain identifiers. + + The sender-side conversion AdCP 3.3 asks for before submission: every + ``domain`` / ``subdomain`` identifier in ``base_properties[]`` and + ``filters.exclude_identifiers[]``, and every ``base_properties[]`` + ``publisher_domain``, is lowercased, converted to its IDNA A-label and + stripped of surrounding whitespace and a trailing dot (``Example.com.`` + becomes ``example.com``). A value that cannot be converted (a URL, a port, + an IP address, a misplaced wildcard, an invalid IDN) raises + :class:`PropertyListIdentifierError`; it is never silently reinterpreted. + In particular a pasted URL is refused rather than reduced to its host: + extract the hostname yourself once you have confirmed that is intended. + + Returns a new request of the same kind (a generated request model or a + mapping); other identifier types are left unchanged. Receivers enforce the + format without rewriting, so convert before calling ``create_property_list`` + or ``update_property_list``. + """ + wire = _wire_request(request) + for path, item in _identifier_slots(wire): + kind, value = item.get("type"), item.get("value") + if kind not in {"domain", "subdomain"}: + continue + try: + # normalize_identifier refuses a non-string value (expected_string). + item["value"] = normalize_identifier(kind, cast(str, value)).value + except IdentifierValidationError as exc: + raise PropertyListIdentifierError(f"{path}.value", str(kind), exc.reason) from None + for index, source in enumerate(wire.get("base_properties") or ()): + if isinstance(source, dict) and "publisher_domain" in source: + try: + source["publisher_domain"] = normalize_domain( + source["publisher_domain"], allow_wildcard=False + ).value + except IdentifierValidationError as exc: + raise PropertyListIdentifierError( + f"base_properties[{index}].publisher_domain", "domain", exc.reason + ) from None + if hasattr(request, "model_validate"): + return type(request).model_validate(wire) # type: ignore[no-any-return] + return wire # type: ignore[return-value] + + +def validate_property_list_identifiers(request: Any) -> None: + """Reject a property-list request whose domain identifiers are malformed. + + Receiver side of the AdCP 3.3 rule: an invalid ``domain`` / ``subdomain`` + value in ``base_properties[]`` or ``filters.exclude_identifiers[]`` fails + the whole request with ``INVALID_REQUEST`` (``field`` names the first + offending value); nothing is rewritten or dropped. The decisioning + framework runs this before ``create_property_list`` / ``update_property_list`` + reach the platform. + """ + from adcp.decisioning.types import AdcpError + + for path, item in _identifier_slots(_identifier_members(request)): + kind = item.get("type") + if isinstance(kind, str) and not is_valid_domain_identifier(kind, item.get("value")): + raise AdcpError( + "INVALID_REQUEST", + message=( + f"{kind} identifiers must be lowercase ASCII hostnames (IDNs in " + "A-label form) without scheme, path, port, whitespace or trailing " + "dot; only domain permits a leading '*.'." + ), + recovery="correctable", + field=f"{path}.value", + suggestion=( + "convert the value before submission, e.g. with " + "adcp.property_lists.normalize_property_list_identifiers" + ), + ) + def field(value: Any, name: str, default: Any = None) -> Any: return value.get(name, default) if isinstance(value, Mapping) else getattr(value, name, default) @@ -191,6 +365,12 @@ async def resolve(self, ref: Any) -> ResolvedPropertyList: raise ValueError("property list exceeded max_identifiers") for item in raw_identifiers: identifier = normalize_identifier(field(item, "type"), field(item, "value")) + # AdCP 3.3: an invalid domain identifier in a fetched + # page invalidates the whole snapshot; never rewrite it. + if not is_valid_domain_identifier( + identifier.type, identifier.original_value + ): + raise ValueError("property list identifier is not a valid hostname") # Include source/canonical strings and conservative object overhead # so tiny pages cannot evade the aggregate response budget. resolution_bytes += ( @@ -489,9 +669,13 @@ async def check_property_list_reference( __all__ = [ "MCPPropertyListFetcher", + "PropertyListIdentifierError", "ResolvedPropertyList", "ProductPropertyPolicy", "ProductPropertyAssessment", "check_property_list_reference", + "is_valid_domain_identifier", + "normalize_property_list_identifiers", "request_property_list", + "validate_property_list_identifiers", ] diff --git a/src/adcp/server/principal.py b/src/adcp/server/principal.py index 03d69a70e..e8fbf7c9e 100644 --- a/src/adcp/server/principal.py +++ b/src/adcp/server/principal.py @@ -410,7 +410,11 @@ def __init__( destination_proof: DestinationProofHook | None = None, notification_proof: NotificationProofHook | None = None, emit_change: PrincipalChangeEmitter | None = None, - accepted_async_adcp_versions: tuple[str, ...] = ("3.2",), + # Minor versions (the schema pattern is ``^\d+\.\d+$``) whose async + # payload shapes this seller emits. The SDK serves the 3.3 line by + # default (3.3-beta.1 declares as "3.3"); a seller pinned to 3.2 + # passes ``("3.2",)``. + accepted_async_adcp_versions: tuple[str, ...] = ("3.2", "3.3"), accepted_webhook_signing_algorithms: tuple[str, ...] = (), accepted_experimental_features: tuple[str, ...] = (), relationship_resolver: ( diff --git a/tests/test_decisioning_33_seller_rules.py b/tests/test_decisioning_33_seller_rules.py new file mode 100644 index 000000000..1571c91fe --- /dev/null +++ b/tests/test_decisioning_33_seller_rules.py @@ -0,0 +1,676 @@ +"""AdCP 3.3 seller-side rules owned by the decisioning framework (#1668). + +Covers version literals that must include the 3.3 line, the +``sales-fixed-rate`` specialism, advertised measurement terms, property domain +identifiers, the counterparty-asserted domain-list bounds and the +``CREATIVE_MERGE_REJECTED`` / ``RIGHTS_CHANNEL_VIOLATION`` helpers. Wire shapes +are checked against the bundled 3.3 JSON schemas, not mocks. +""" + +from __future__ import annotations + +import asyncio +import json +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any + +import pytest +from pydantic import ValidationError as PydanticValidationError + +from adcp.decisioning import ( + CreativeMergeRejectedError, + DecisioningCapabilities, + DecisioningPlatform, + InMemoryTaskRegistry, + RightsChannelViolationError, + SingletonAccounts, + TermsRejectedError, + bound_counterparty_domains, + creative_merge_rejected_details, + is_advertised_terms_replay, + rights_channel_violation_details, +) +from adcp.decisioning.capabilities import ( + Account, + MediaBuy, + Portfolio, + Signals, + SupportedProtocol, +) +from adcp.decisioning.dispatch import SPEC_SPECIALISM_ENUM +from adcp.decisioning.domain_lists import ( + COUNTERPARTY_DOMAIN_LIST_BYTE_BUDGET, + bound_capability_domain_lists, +) +from adcp.decisioning.handler import ( + SPECIALISM_TO_PROTOCOLS, + PlatformHandler, + _is_adcp_32_or_newer, +) +from adcp.decisioning.measurement_terms import undeclared_measurement_window +from adcp.decisioning.types import AdcpError +from adcp.property_lists import ( + PropertyListIdentifierError, + is_valid_domain_identifier, + normalize_property_list_identifiers, + validate_property_list_identifiers, +) +from adcp.server import PrincipalIdentity, PrincipalService +from adcp.server.base import ToolContext +from adcp.types import ( + CreatePropertyListRequest, + GetProductsRequest, + GetProductsResponse, + Product, + UpdatePropertyListRequest, +) +from adcp.validation.schema_validator import validate_request, validate_response + +SCHEMAS = Path(__file__).resolve().parents[1] / "schemas" / "cache" / "3.3.0-beta.1" + + +@pytest.fixture +def executor(): + pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="test-33-rules-") + yield pool + pool.shutdown(wait=True) + + +def _handler(platform: DecisioningPlatform, executor: ThreadPoolExecutor) -> PlatformHandler: + return PlatformHandler(platform, executor=executor, registry=InMemoryTaskRegistry()) + + +# --------------------------------------------------------------------------- +# 1. Version literals include the 3.3 line +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "version,expected", + [ + (None, True), + ("3.2", True), + ("3.3-beta.1", True), + # Bundle precision used to fail to parse and read as pre-3.2. + ("3.3.0-beta.1", True), + ("3.2.4", True), + ("3.1", False), + ("3.0.7", False), + ("not-a-version", False), + ], +) +def test_signing_capability_gate_counts_3_3_as_3_2_or_newer(version, expected) -> None: + assert _is_adcp_32_or_newer(version) is expected + + +@pytest.mark.asyncio +async def test_principal_service_accepts_3_3_async_payloads_by_default() -> None: + service = PrincipalService() + response = await service.sync_principal( + PrincipalIdentity("https://buyer.example/agent", "buyer_agent"), + { + "idempotency_key": "principal-3-3-async-0001", + "configuration": {"declarations": {"async_adcp_versions": ["3.3", "3.2"]}}, + }, + ) + + declarations = response.result.configuration.declarations + assert list(declarations.accepted.async_adcp_versions) == ["3.3", "3.2"] + assert declarations.selected_async_adcp_version == "3.3" + assert declarations.exclusions is None + + +# --------------------------------------------------------------------------- +# 2. sales-fixed-rate +# --------------------------------------------------------------------------- + + +def test_every_spec_sales_specialism_rolls_up_to_media_buy() -> None: + enum = json.loads((SCHEMAS / "enums" / "specialism.json").read_text())["enum"] + sales = {slug for slug in enum if slug.startswith("sales-")} + + assert "sales-fixed-rate" in sales + assert sales <= SPEC_SPECIALISM_ENUM + for slug in sales: + assert SPECIALISM_TO_PROTOCOLS.get(slug) == frozenset({"media_buy"}), slug + + +@pytest.mark.filterwarnings("ignore:DecisioningCapabilities.supported_protocols") +def test_fixed_rate_platform_derives_media_buy_and_advertises_the_specialism(executor) -> None: + class _FixedRateSeller(DecisioningPlatform): + capabilities = DecisioningCapabilities( + specialisms=["sales-fixed-rate"], + account=Account(supported_billing=["operator"]), + media_buy=MediaBuy(supported_pricing_models=["cpp", "cpm"]), + ) + accounts = SingletonAccounts(account_id="fixed-rate") + + response = asyncio.run(_handler(_FixedRateSeller(), executor).get_adcp_capabilities()) + + # The sales_fixed_rate storyboard's capability_discovery step. + assert response["supported_protocols"] == ["media_buy"] + assert response["specialisms"] == ["sales-fixed-rate"] + assert validate_response("get_adcp_capabilities", response).valid + + +# --------------------------------------------------------------------------- +# 3. Advertised measurement terms +# --------------------------------------------------------------------------- + +_PRODUCT: dict[str, Any] = { + "product_id": "spot_q4", + "name": "Q4 spots", + "description": "Fixed-rate spot package", + "publisher_properties": [{"selection_type": "all", "publisher_domain": "pub.example.com"}], + "format_options": [ + { + "format_option_id": "video_30s", + "format_kind": "video_hosted", + "params": {"duration_ms_exact": 30000}, + } + ], + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_model": "cpm", + "pricing_option_id": "cpm_fixed", + "currency": "USD", + "fixed_price": 12.0, + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["daily"], + "expected_delay_minutes": 60, + "timezone": "UTC", + "supports_webhooks": False, + "available_metrics": ["impressions"], + "date_range_support": "date_range", + "measurement_windows": [ + {"window_id": "live", "duration_days": 0}, + {"window_id": "c7", "duration_days": 7}, + ], + }, + "measurement_terms": { + "billing_measurement": { + "vendor": {"domain": "measurement.example"}, + "measurement_window": "c7", + }, + "makegood_policy": {"available_remedies": ["additional_delivery"]}, + }, +} + + +def _product(**overrides: Any) -> Product: + return Product.model_validate({**_PRODUCT, **overrides}) + + +def _with_window(window: str) -> Product: + terms = json.loads(json.dumps(_PRODUCT["measurement_terms"])) + terms["billing_measurement"]["measurement_window"] = window + return _product(measurement_terms=terms) + + +def test_declared_window_passes_and_undeclared_window_is_reported() -> None: + assert undeclared_measurement_window(_product()) is None + assert undeclared_measurement_window(_with_window("c3")) == "c3" + no_windows = json.loads(json.dumps(_PRODUCT["reporting_capabilities"])) + del no_windows["measurement_windows"] + assert undeclared_measurement_window(_product(reporting_capabilities=no_windows)) == "c7" + assert undeclared_measurement_window({"product_id": "no_terms"}) is None + + +class _CatalogSeller(DecisioningPlatform): + capabilities = DecisioningCapabilities( + specialisms=["sales-fixed-rate"], + supported_protocols=[SupportedProtocol.media_buy], + ) + accounts = SingletonAccounts(account_id="catalog") + + def __init__(self, products: list[Product]) -> None: + self.products = products + + def get_products(self, req, ctx): + return GetProductsResponse(products=self.products) + + +@pytest.mark.asyncio +async def test_get_products_refuses_a_product_advertising_an_undeclared_window(executor) -> None: + handler = _handler(_CatalogSeller([_product(), _with_window("c3")]), executor) + + with pytest.raises(AdcpError) as exc: + await handler.get_products( + GetProductsRequest(buying_mode="brief", brief="spots"), ToolContext() + ) + + assert exc.value.code == "INTERNAL_ERROR" + assert exc.value.field == "products[1].measurement_terms.billing_measurement.measurement_window" + + +@pytest.mark.asyncio +async def test_get_products_serves_products_whose_windows_are_declared(executor) -> None: + handler = _handler(_CatalogSeller([_product()]), executor) + + response = await handler.get_products( + GetProductsRequest(buying_mode="brief", brief="spots"), ToolContext() + ) + + assert [p.product_id for p in response.products] == ["spot_q4"] + assert validate_response("get_products", response).valid + + +def test_replay_of_advertised_terms_is_detected() -> None: + advertised = _product().measurement_terms + + assert is_advertised_terms_replay(advertised, advertised) + # A term-by-term replay (one advertised term, unchanged) is still a replay. + assert is_advertised_terms_replay( + {"billing_measurement": _PRODUCT["measurement_terms"]["billing_measurement"]}, advertised + ) + changed = json.loads(json.dumps(_PRODUCT["measurement_terms"])) + changed["billing_measurement"]["measurement_window"] = "live" + assert not is_advertised_terms_replay(changed, advertised) + dropped_window = {"billing_measurement": {"vendor": {"domain": "measurement.example"}}} + assert not is_advertised_terms_replay(dropped_window, advertised) + assert not is_advertised_terms_replay({}, advertised) + + +def test_terms_rejected_refuses_to_reject_a_replay() -> None: + advertised = _PRODUCT["measurement_terms"] + with pytest.raises(ValueError, match="replay"): + TermsRejectedError(proposed_terms=advertised, advertised_terms=advertised) + + proposal = {"billing_measurement": {"vendor": {"domain": "other-vendor.example"}}} + error = TermsRejectedError( + proposed_terms=proposal, + advertised_terms=advertised, + term="billing_measurement", + field="packages[0].measurement_terms.billing_measurement.vendor", + ) + assert error.to_wire() == { + "code": "TERMS_REJECTED", + "message": "Proposed measurement terms were rejected.", + "recovery": "correctable", + "field": "packages[0].measurement_terms.billing_measurement.vendor", + "details": {"term": "billing_measurement"}, + } + + +# --------------------------------------------------------------------------- +# 4. Property domain identifiers +# --------------------------------------------------------------------------- + +_IDENTIFIER_VALUES = [ + "example.com", + "Example.com", + "example.com.", + " example.com", + "example.com\n", + "https://www.example.com/path", + "example.com:443", + "*.example.com", + "*.Example.com", + "a.*.example.com", + "xn--strae-oqa.de", + "straße.de", + "localhost", + "-bad.example.com", + "a" * 64 + ".example.com", +] + + +@pytest.mark.parametrize("identifier_type", ["domain", "subdomain"]) +@pytest.mark.parametrize("value", _IDENTIFIER_VALUES) +def test_identifier_check_matches_the_3_3_identifier_schema(identifier_type, value) -> None: + payload = { + "name": "Blocklist", + "idempotency_key": "property-list-key-0001", + "filters": {"exclude_identifiers": [{"type": identifier_type, "value": value}]}, + } + schema_valid = validate_request("create_property_list", payload).valid + + assert is_valid_domain_identifier(identifier_type, value) is schema_valid + + +def test_non_domain_identifier_types_are_not_constrained() -> None: + assert is_valid_domain_identifier("bundle_id", "Com.Example.App") + + +def _create_request(*values: str, publisher_domain: str | None = None) -> dict[str, Any]: + base: list[dict[str, Any]] = [ + { + "selection_type": "identifiers", + "identifiers": [{"type": "domain", "value": value} for value in values], + } + ] + if publisher_domain is not None: + base.append( + { + "selection_type": "publisher_tags", + "publisher_domain": publisher_domain, + "tags": ["premium"], + } + ) + return { + "name": "Allowlist", + "idempotency_key": "property-list-key-0002", + "base_properties": base, + "filters": { + "exclude_identifiers": [{"type": "subdomain", "value": "Edition.Example.com."}] + }, + } + + +def test_sender_normalizer_converts_explicitly_and_the_result_validates() -> None: + request = _create_request("Example.COM.", "*.News.Example.com", "straße.de") + + converted = normalize_property_list_identifiers(request) + + assert [i["value"] for i in converted["base_properties"][0]["identifiers"]] == [ + "example.com", + "*.news.example.com", + "xn--strae-oqa.de", + ] + assert converted["filters"]["exclude_identifiers"][0]["value"] == "edition.example.com" + assert request["base_properties"][0]["identifiers"][0]["value"] == "Example.COM." + assert validate_request("create_property_list", converted).valid + assert not validate_request("create_property_list", request).valid + + +def test_sender_normalizer_keeps_the_model_type_and_normalizes_publisher_domain() -> None: + model = UpdatePropertyListRequest.model_validate( + {**_create_request("Example.com"), "list_id": "list_1"} + ) + + converted = normalize_property_list_identifiers(model) + + assert isinstance(converted, UpdatePropertyListRequest) + wire = converted.model_dump(mode="json", exclude_none=True) + assert wire["base_properties"][0]["identifiers"][0]["value"] == "example.com" + + +@pytest.mark.parametrize( + "value,reason", + [ + ("https://www.example.com/path", "expected_hostname"), + ("example.com:443", "expected_hostname"), + ("a.*.example.com", "expected_hostname"), + ("192.0.2.1", "ip_address_not_allowed"), + ], +) +def test_sender_normalizer_refuses_what_it_cannot_convert(value, reason) -> None: + with pytest.raises(PropertyListIdentifierError) as exc: + normalize_property_list_identifiers(_create_request(value)) + + assert exc.value.field == "base_properties[0].identifiers[0].value" + assert exc.value.reason == reason + assert value not in str(exc.value) + + +def test_sender_normalizer_refuses_a_url_publisher_domain() -> None: + with pytest.raises(PropertyListIdentifierError) as exc: + normalize_property_list_identifiers( + _create_request("example.com", publisher_domain="https://pub.example.com") + ) + assert exc.value.field == "base_properties[1].publisher_domain" + + +def test_receiver_rejects_the_whole_request_without_rewriting() -> None: + request = _create_request("example.com", "Example.org") + request["filters"]["exclude_identifiers"][0]["value"] = "edition.example.com" + + with pytest.raises(AdcpError) as exc: + validate_property_list_identifiers(request) + + assert exc.value.code == "INVALID_REQUEST" + assert exc.value.recovery == "correctable" + assert exc.value.field == "base_properties[0].identifiers[1].value" + validate_property_list_identifiers(normalize_property_list_identifiers(request)) + + +class _ListsPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities( + specialisms=["property-lists"], + supported_protocols=[SupportedProtocol.governance], + ) + accounts = SingletonAccounts(account_id="lists") + + def __init__(self) -> None: + self.calls: list[Any] = [] + + def create_property_list(self, req, ctx): + self.calls.append(req) + return {"list": {"list_id": "list_1", "name": req.name}, "auth_token": "token"} + + +@pytest.mark.asyncio +async def test_create_property_list_rejects_mixed_case_domains_before_the_platform( + executor, +) -> None: + platform = _ListsPlatform() + handler = _handler(platform, executor) + + with pytest.raises(AdcpError) as exc: + await handler.create_property_list( + CreatePropertyListRequest.model_validate(_create_request("Example.com")), + ToolContext(), + ) + + assert exc.value.code == "INVALID_REQUEST" + assert exc.value.field == "base_properties[0].identifiers[0].value" + assert platform.calls == [] + + accepted = normalize_property_list_identifiers( + CreatePropertyListRequest.model_validate(_create_request("Example.com")) + ) + await handler.create_property_list(accepted, ToolContext()) + assert len(platform.calls) == 1 + + +# --------------------------------------------------------------------------- +# 5. Counterparty-asserted domain lists +# --------------------------------------------------------------------------- + + +def _domains(count: int, *, width: int = 0) -> list[str]: + return [f"pub{index}{'x' * width}.example" for index in range(count)] + + +def _member_bytes(field: str, domains: list[str]) -> int: + return len(json.dumps({field: domains}, separators=(",", ":")).encode()) - 2 + + +def test_short_list_is_kept_whole_and_complete() -> None: + domains = _domains(3) + bounded = bound_counterparty_domains(domains) + + assert bounded.domains == tuple(domains) + assert bounded.complete is True + assert bounded.serialized_bytes == _member_bytes("publisher_domains", domains) + + +def test_entry_bound_truncates_to_100_in_declared_order() -> None: + bounded = bound_counterparty_domains(_domains(150)) + + assert bounded.domains == tuple(_domains(100)) + assert bounded.complete is False + + +def test_byte_budget_counts_member_name_and_punctuation() -> None: + domains = _domains(60, width=180) + bounded = bound_counterparty_domains(domains, field="data_provider_domains") + + size = _member_bytes("data_provider_domains", list(bounded.domains)) + assert bounded.serialized_bytes == size <= COUNTERPARTY_DOMAIN_LIST_BYTE_BUDGET + next_entry = len(json.dumps(domains[len(bounded.domains)])) + 1 + assert size + next_entry > COUNTERPARTY_DOMAIN_LIST_BYTE_BUDGET + assert bounded.complete is False + + +def test_duplicates_are_dropped_without_marking_the_list_incomplete() -> None: + bounded = bound_counterparty_domains(["a.example", "b.example", "a.example"]) + + assert bounded.domains == ("a.example", "b.example") + assert bounded.complete is True + + +def test_over_length_entries_are_dropped_and_mark_the_list_incomplete() -> None: + long_domain = ("a" * 62 + ".") * 4 + "example" + assert len(long_domain) > 253 + bounded = bound_counterparty_domains(["a.example", long_domain]) + + assert bounded.domains == ("a.example",) + assert bounded.complete is False + with pytest.raises(ValueError): + bound_counterparty_domains([long_domain]) + + +def test_combined_budget_binds_the_second_list() -> None: + response = { + "media_buy": {"portfolio": {"publisher_domains": _domains(5)}}, + "signals": {"data_provider_domains": _domains(5)}, + } + first = _member_bytes("publisher_domains", _domains(5)) + + assert bound_capability_domain_lists(response, combined_byte_budget=first + 60) is True + assert response["media_buy"]["portfolio"] == {"publisher_domains": _domains(5)} + assert response["signals"]["data_provider_domains_complete"] is False + kept = response["signals"]["data_provider_domains"] + assert 0 < len(kept) < 5 + assert _member_bytes("data_provider_domains", kept) <= 60 + + +@pytest.mark.asyncio +async def test_capabilities_projection_truncates_and_flags_oversized_lists(executor) -> None: + class _NetworkSeller(DecisioningPlatform): + capabilities = DecisioningCapabilities( + specialisms=["sales-non-guaranteed", "signal-marketplace"], + supported_protocols=[SupportedProtocol.media_buy, SupportedProtocol.signals], + account=Account(supported_billing=["operator"]), + media_buy=MediaBuy( + supported_pricing_models=["cpm"], + portfolio=Portfolio( + publisher_domains=_domains(400, width=40), primary_channels=["display"] + ), + ), + signals=Signals(data_provider_domains=_domains(3)), + ) + accounts = SingletonAccounts(account_id="network") + + response = await _handler(_NetworkSeller(), executor).get_adcp_capabilities() + + portfolio = response["media_buy"]["portfolio"] + assert portfolio["publisher_domains_complete"] is False + assert ( + portfolio["publisher_domains"] + == _domains(400, width=40)[: len(portfolio["publisher_domains"])] + ) + assert _member_bytes("publisher_domains", portfolio["publisher_domains"]) <= 8192 + assert response["signals"]["data_provider_domains"] == _domains(3) + assert "data_provider_domains_complete" not in response["signals"] + assert validate_response("get_adcp_capabilities", response).valid + + +# --------------------------------------------------------------------------- +# 7. CREATIVE_MERGE_REJECTED / RIGHTS_CHANNEL_VIOLATION +# --------------------------------------------------------------------------- + + +def _validate_details(schema_file: str, details: dict[str, Any]) -> None: + import jsonschema + + schema = json.loads((SCHEMAS / "error-details" / schema_file).read_text()) + enum = json.loads((SCHEMAS / "enums" / "creative-merge-rejection-reason.json").read_text()) + schema["properties"]["rejections"]["items"]["properties"]["reason"] = enum + jsonschema.Draft7Validator(schema).validate(details) + + +def test_creative_merge_rejected_mints_schema_valid_details() -> None: + error = CreativeMergeRejectedError( + rejections=[ + { + "operation_index": 3, + "creative_id": "cr_buyer_1", + "package_id": "pkg_1", + "merged_creative_id": "cr_seller_9", + "reason": "content_mismatch", + }, + { + "operation_index": 1, + "creative_id": "cr_buyer_2", + "package_id": "pkg_1", + "merged_creative_id": "cr_seller_8", + "reason": "not_on_package", + }, + ] + ) + wire = error.to_wire() + + assert wire["code"] == "CREATIVE_MERGE_REJECTED" + assert wire["recovery"] == "correctable" + assert wire["field"] == "assignment_operations[1]" + _validate_details("creative-merge-rejected.json", wire["details"]) + + narrowed = creative_merge_rejected_details(wire) + assert narrowed is not None + assert [r.merged_creative_id for r in narrowed.rejections] == ["cr_seller_9", "cr_seller_8"] + assert creative_merge_rejected_details(error) == narrowed + + +def test_creative_merge_rejected_refuses_a_reason_outside_the_enum() -> None: + with pytest.raises(PydanticValidationError): + CreativeMergeRejectedError( + rejections=[ + { + "operation_index": 0, + "creative_id": "a", + "package_id": "p", + "merged_creative_id": "b", + "reason": "looks_different", + } + ] + ) + + +def test_merge_narrowing_ignores_other_codes_and_nonconforming_details() -> None: + assert creative_merge_rejected_details({"code": "VALIDATION_ERROR", "details": {}}) is None + assert creative_merge_rejected_details({"code": "CREATIVE_MERGE_REJECTED"}) is None + assert ( + creative_merge_rejected_details( + {"code": "CREATIVE_MERGE_REJECTED", "details": {"rejections": []}} + ) + is None + ) + + +def test_rights_channel_violation_mints_and_narrows() -> None: + error = RightsChannelViolationError( + rights_id="rights_42", + channel="ctv", + capability_id="cap_ctv_30s", + field="creative_manifest.rights[0].excluded_channels", + ) + wire = error.to_wire() + + assert wire["code"] == "RIGHTS_CHANNEL_VIOLATION" + assert wire["recovery"] == "correctable" + assert wire["field"] == "creative_manifest.rights[0].excluded_channels" + assert wire["details"] == { + "rights_id": "rights_42", + "channel": "ctv", + "capability_id": "cap_ctv_30s", + } + # Advisory entry on an otherwise successful build: errors[] items are core/error.json. + error_schema = json.loads((SCHEMAS / "core" / "error.json").read_text()) + assert set(wire) <= set(error_schema["properties"]) + + narrowed = rights_channel_violation_details(wire) + assert narrowed is not None + assert (narrowed.rights_id, narrowed.channel, narrowed.capability_id) == ( + "rights_42", + "ctv", + "cap_ctv_30s", + ) + partial = rights_channel_violation_details({"code": "RIGHTS_CHANNEL_VIOLATION"}) + assert partial is not None and partial.rights_id is None + assert rights_channel_violation_details({"code": "CREATIVE_REJECTED"}) is None diff --git a/tests/test_property_lists.py b/tests/test_property_lists.py index d02d4cefb..64a15e248 100644 --- a/tests/test_property_lists.py +++ b/tests/test_property_lists.py @@ -86,7 +86,7 @@ async def test_pages_types_expiry_and_auth_isolated_cache(monkeypatch): monkeypatch, fetcher, [ - page([{"type": "domain", "value": "EXAMPLE.com"}], cursor="next", expiry=expiry), + page([{"type": "domain", "value": "example.com"}], cursor="next", expiry=expiry), page([{"type": "roku_store_id", "value": "123"}], expiry=expiry), page([], expiry=expiry), ], @@ -146,6 +146,13 @@ async def test_earliest_page_expiry_and_compliance_probe_bypasses_cache(monkeypa ) ], [TaskResult(status=TaskStatus.FAILED, success=False, error="secret")], + # AdCP 3.3: an invalid domain identifier on any page invalidates the + # whole snapshot instead of being rewritten (here on the second page). + [ + page([{"type": "domain", "value": "example.com"}], cursor="next"), + page([{"type": "domain", "value": "EXAMPLE.com"}]), + ], + [page([{"type": "subdomain", "value": "news.example.com."}])], ], ) async def test_bad_pages_fail_without_credential_leak(monkeypatch, pages, caplog):