Repository navigation
feat(decisioning)!: AdCP 3.3 seller-side rules - #1720
Conversation
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) <noreply@anthropic.com>
| byte_budget=min(COUNTERPARTY_DOMAIN_LIST_BYTE_BUDGET, remaining), | ||
| ) | ||
| remaining -= bounded.serialized_bytes | ||
| if bounded.complete: |
There was a problem hiding this comment.
Medium: The projection skips the write-back whenever bounded.complete is true — but bound_counterparty_domains reports complete=True when the only entries it dropped were duplicates (L367: complete=len(kept) == len(set(declared))). So a declared list that fits after dedup but carries duplicates is left on the wire un-deduplicated: parent[field] (L163) never runs. A seller mirroring adagents.json with 120 entries / 90 unique keeps all 120 on the wire, no *_complete: false set — and the raw 120-entry serialization can exceed the 8,192-byte / 100-entry bound while served as complete, the exact MUST this projection exists to enforce. Write back bounded.domains whenever it differs from the input, not only when complete is false. The dedup path is covered for the helper (test_duplicates_are_dropped...) but not for bound_capability_domain_lists.
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean AdCP 3.3 seller-side rules addition to adcp.decisioning with correct breaking-change signaling.
What I checked:
- Breaking surface change correctly marked
feat(decisioning)!with BREAKING CHANGE footer +MIGRATION_v9_to_v10.md— satisfies the mandatory semver-signal gate. - Import layering, generated-code boundaries, ctx_metadata credential rules, and CI gates all clean per reviewer.
- 676-test suite validates against bundled 3.3 schemas; oversized-list truncation path covered.
high_risk: truebut the triggering entries are new files (domain_lists.py,measurement_terms.pyadded) and modifications with no high/critical findings — new scaffolding on high-risk globs is normal, not escalation-worthy. No(deleted)entries.
Medium findings (1):
- src/adcp/decisioning/domain_lists.py:160 — Capabilities projection serves un-deduplicated list as complete, can exceed the 8,192-byte/100-entry bound. Worth a follow-up but not blocking.
Decision path: No critical/high findings (row 1 n/a). gated_paths: false (row 2 n/a). No (deleted) high-risk entry (row 3 n/a). The single medium finding's category is a logic/edge-case gap, not data-loss/schema/infra (row 4 n/a). The medium does not sit on a (modified) high-risk file that carries it into row 5 in a way that's not — actually domain_lists.py is (added), not (modified), so row 5 n/a. No sticky prior escalation (row 6 n/a). No author-team gate (row 7 n/a). Only 1 medium finding, below the 3-medium threshold (row 8 n/a). Falls through to row 9: approve.
Medium findings
- src/adcp/decisioning/domain_lists.py:160 — Capabilities projection serves un-deduplicated list as complete, can exceed bound
Implements the framework parts of #1668 (AdCP 3.3 seller-side rules in
adcp.decisioning).Per-item triage
"3.2"/startswith("3.")/ tuple check. Already included 3.3:decisioning/dispatch.pylegacy-format gate andhandler.pyproposal-manager projection (is_adcp_version_at_least(..., "3.2")),accounts.pykey-continuity gate,server/principal.pyrelationship gate, andsigning/autosign.signing_profile_for_adcp_version(maps 3.3 onto the 3.2 RFC 9421 profile). The"3.2"literals left insigning/*(SigningProfileVersion,jws_artifactsprofile) andgovernance.pyname the 3.2 signing / JWS profile, not a release floor. Fixed here:handler._is_adcp_32_or_newertreated bundle precision3.3.0-beta.1as pre-3.2 (int("3.0")raised);PrincipalServicedefaultaccepted_async_adcp_versionswas("3.2",)and is now("3.2", "3.3")(schema pattern^\d+\.\d+$, so 3.3-beta.1 declares as"3.3"); account key continuity reduced from source"3.2"and now reduces from the SDK's own release. Not fixed (outside write scope):media_buy_actions._supports_change_term_id("3.3-beta.1")returnsFalse; see follow-up below.sales-fixed-rateREQUIRED_METHODS/ recommended methods / advertised tools were already there from the 3.3 adoption. The missing piece wasSPECIALISM_TO_PROTOCOLS, so a seller claiming onlysales-fixed-ratederived an emptysupported_protocols. A test now checks that everysales-*value in the spec enum rolls up tomedia_buy. The packaged compliance assets (src/adcp/_compliance/3.3.0-beta.1/) carry onlyuniversal/and test vectors, with no specialism storyboards, sosales_fixed_rateruns from the protocol's compliance tree under the TS runner. The SDK doesn't serve it. Provisionalconfirmed_at: nullbuys: the framework never auto-activates a buy, and the response schema'sif confirmed_at null then not activecovers the specialism's needs. The flight-start rule stays with #1644.get_products/list_products(sync arm) fail withINTERNAL_ERRORwhen a product'smeasurement_terms.billing_measurement.measurement_windowisn't a declaredreporting_capabilities.measurement_windows[].window_id(check_advertised_measurement_terms).TermsRejectedError(proposed_terms=, advertised_terms=)raisesValueErrorinstead of minting a rejection of a replay (is_advertised_terms_replay). The framework doesn't look products up atcreate_media_buy, so the replay rule relies on the seller using the helper.PlatformHandler.create_property_list/update_property_listreject invaliddomain/subdomainidentifiers inbase_properties[].identifiers[]andfilters.exclude_identifiers[]withINVALID_REQUESTbefore the platform runs.MCPPropertyListFetchernow fails a snapshot that carries one; previously it canonicalized it. Sender normalizer:adcp.property_lists.normalize_property_list_identifiers(lowercase, IDNA A-label, strips whitespace and trailing dot, also coverspublisher_domain; raisesPropertyListIdentifierErrorfor URLs, ports, IPs and misplaced wildcards).is_valid_domain_identifieris checked against the bundled 3.3 schema for 30 values.adcp.decisioning.bound_counterparty_domainskeeps declared order, drops duplicates (the list stays complete) and entries over 253 characters (the list becomes incomplete), cuts at 100 entries and 8,192 bytes of compact"<field>":[...], and reportscomplete. Theget_adcp_capabilitiesprojection applies the per-field and 16,384-byte combined budgets, truncates, sets*_complete: falseand logs a warning. The 3.3 → 3.2 conversion drops the flag. The SDK has no buyer-side reader that treats either list as an allowlist.build_creativerefinement /COMPLIANCE_UNSATISFIEDbuild_creative(includingrefine_from_build_variant_id) straight to the platform and retains no leaf compliance inputs, so it doesn't own refinement.CREATIVE_MERGE_REJECTED/RIGHTS_CHANNEL_VIOLATIONCreativeMergeRejectedErrorvalidatesrejectionsagainst the generatedCreativeMergeRejectedDetailsand defaultsfieldto the first rejectedassignment_operations[i].RightsChannelViolationErrorcarriesrights_id,channelandcapability_id;to_wire()gives an advisoryerrors[]entry. Narrowing:creative_merge_rejected_details(error)andrights_channel_violation_details(error).evaluator_idSpec basis
core/measurement-terms.jsonmeasurement_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) … that it would reject with TERMS_REJECTED when a buyer replays those terms unchanged on create_media_buy."core/identifier.jsonvalue: "Domain and subdomain values MUST be lowercase ASCII hostnames (IDNs use lowercase A-label/punycode form), without scheme, path, port, whitespace, or trailing dot … Invalid values MUST cause rejection of the containing request." Migration guide: "An invalid identifier in a fetched page invalidates the complete snapshot", and senders should "Offer explicit conversion before submission … receivers MUST NOT silently reinterpret it." The versioning doc lists this under "3.3 exceptions to 3.x compatibility", so it applies whatever release the request was served at.security.mdx§Response Size Bounds: "A single counterparty-asserted field MUST NOT exceed … 8,192 bytes, and all such fields together MUST NOT exceed … 16,384 bytes. Measure uncompressed serialized JSON — the member name, the array punctuation, and the quoted elements";x-adcp-validation.advisory_boundsgivesmax_items: 100,item_max_length: 253; "MUST truncate and mark the list incomplete".enums/error-code.jsondescriptions forCREATIVE_MERGE_REJECTEDandRIGHTS_CHANNEL_VIOLATION, anderror-details/creative-merge-rejected.json.enums/specialism.jsonsales-fixed-rate.Tests
tests/test_decisioning_33_seller_rules.py(67 tests) checks against the bundled 3.3 schemas:validate_request/validate_response, and jsonschema for the merge details.tests/test_property_lists.pyis updated, because a mixed-case domain on a fetched page now fails the resolution. Locally:make lint,make typecheck-alland the targeted decisioning, property-list, principal, account and capabilities suites pass.Follow-up (not in this PR)
media_buy_actions._supports_change_term_id(public viaproject_available_actions(protocol_version=...)) returnsFalsefor3.3-beta.Nwith N < 9, because the beta.9 floor meant for 3.2 betas applies to every line.src/adcp/media_buy_actions.pywas outside this PR's write scope.Part of #1668
BREAKING CHANGE: create_property_list / update_property_list on PlatformHandler fail with INVALID_REQUEST on mixed-case, trailing-dot or URL-shaped domain/subdomain identifiers, and MCPPropertyListFetcher fails a resolution whose pages carry one instead of canonicalizing it. 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 and sets *_complete to false. PrincipalService accepts "3.3" async versions by default. See MIGRATION_v9_to_v10.md.
🤖 Generated with Claude Code