Skip to content

feat(decisioning)!: AdCP 3.3 seller-side rules - #1720

Merged
bokelley merged 1 commit into
nextfrom
claude/issue-1668-decisioning-seller-rules
Oct 30, 2026
Merged

bokelley merged 1 commit into
nextfrom
claude/issue-1668-decisioning-seller-rules

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Implements the framework parts of #1668 (AdCP 3.3 seller-side rules in adcp.decisioning).

Per-item triage

# Item Status
1 Version literals Done, one piece outside this PR's scope. Audited every "3.2" / startswith("3.") / tuple check. Already included 3.3: decisioning/dispatch.py legacy-format gate and handler.py proposal-manager projection (is_adcp_version_at_least(..., "3.2")), accounts.py key-continuity gate, server/principal.py relationship gate, and signing/autosign.signing_profile_for_adcp_version (maps 3.3 onto the 3.2 RFC 9421 profile). The "3.2" literals left in signing/* (SigningProfileVersion, jws_artifacts profile) and governance.py name the 3.2 signing / JWS profile, not a release floor. Fixed here: handler._is_adcp_32_or_newer treated bundle precision 3.3.0-beta.1 as pre-3.2 (int("3.0") raised); PrincipalService default accepted_async_adcp_versions was ("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") returns False; see follow-up below.
2 sales-fixed-rate Done. REQUIRED_METHODS / recommended methods / advertised tools were already there from the 3.3 adoption. The missing piece was SPECIALISM_TO_PROTOCOLS, so a seller claiming only sales-fixed-rate derived an empty supported_protocols. A test now checks that every sales-* value in the spec enum rolls up to media_buy. The packaged compliance assets (src/adcp/_compliance/3.3.0-beta.1/) carry only universal/ and test vectors, with no specialism storyboards, so sales_fixed_rate runs from the protocol's compliance tree under the TS runner. The SDK doesn't serve it. Provisional confirmed_at: null buys: the framework never auto-activates a buy, and the response schema's if confirmed_at null then not active covers the specialism's needs. The flight-start rule stays with #1644.
3 Advertised measurement terms Done. get_products / list_products (sync arm) fail with INTERNAL_ERROR when a product's measurement_terms.billing_measurement.measurement_window isn't a declared reporting_capabilities.measurement_windows[].window_id (check_advertised_measurement_terms). TermsRejectedError(proposed_terms=, advertised_terms=) raises ValueError instead of minting a rejection of a replay (is_advertised_terms_replay). The framework doesn't look products up at create_media_buy, so the replay rule relies on the seller using the helper.
4 Property domain identifiers Done. PlatformHandler.create_property_list / update_property_list reject invalid domain / subdomain identifiers in base_properties[].identifiers[] and filters.exclude_identifiers[] with INVALID_REQUEST before the platform runs. MCPPropertyListFetcher now 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 covers publisher_domain; raises PropertyListIdentifierError for URLs, ports, IPs and misplaced wildcards). is_valid_domain_identifier is checked against the bundled 3.3 schema for 30 values.
5 Counterparty-asserted domain lists Done. adcp.decisioning.bound_counterparty_domains keeps 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 reports complete. The get_adcp_capabilities projection applies the per-field and 16,384-byte combined budgets, truncates, sets *_complete: false and 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.
6 build_creative refinement / COMPLIANCE_UNSATISFIED Not applicable. The SDK passes build_creative (including refine_from_build_variant_id) straight to the platform and retains no leaf compliance inputs, so it doesn't own refinement.
7 CREATIVE_MERGE_REJECTED / RIGHTS_CHANNEL_VIOLATION Done. CreativeMergeRejectedError validates rejections against the generated CreativeMergeRejectedDetails and defaults field to the first rejected assignment_operations[i]. RightsChannelViolationError carries rights_id, channel and capability_id; to_wire() gives an advisory errors[] entry. Narrowing: creative_merge_rejected_details(error) and rights_channel_violation_details(error).
8 evaluator_id Not applicable. No SDK helper mints it; only the generated 3.1 / 3.2 versioned views keep the field.

Spec basis

  • core/measurement-terms.json 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) … that it would reject with TERMS_REJECTED when a buyer replays those terms unchanged on create_media_buy."
  • core/identifier.json value: "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_bounds gives max_items: 100, item_max_length: 253; "MUST truncate and mark the list incomplete".
  • enums/error-code.json descriptions for CREATIVE_MERGE_REJECTED and RIGHTS_CHANNEL_VIOLATION, and error-details/creative-merge-rejected.json.
  • enums/specialism.json sales-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.py is updated, because a mixed-case domain on a fetched page now fails the resolution. Locally: make lint, make typecheck-all and 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 via project_available_actions(protocol_version=...)) returns False for 3.3-beta.N with N < 9, because the beta.9 floor meant for 3.2 betas applies to every line. src/adcp/media_buy_actions.py was 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

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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: true but the triggering entries are new files (domain_lists.py, measurement_terms.py added) 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

@bokelley
bokelley merged commit fbf13c9 into next Oct 30, 2026
41 checks passed
@bokelley
bokelley deleted the claude/issue-1668-decisioning-seller-rules branch October 30, 2026 02:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant