Skip to content

feat(client): refuse start_requires_all_creatives_approved: true to a seller known not to declare it - #1878

Merged
bokelley merged 1 commit into
nextfrom
feat/1876-buyer-creative-start-policy-guard
Nov 8, 2026
Merged

bokelley merged 1 commit into
nextfrom
feat/1876-buyer-creative-start-policy-guard

Conversation

@bokelley

@bokelley bokelley commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

Summary

AdCP 3.3: buyers MUST NOT send packages[].start_requires_all_creatives_approved: true on control_media_buy to a seller that does not declare media_buy.features.creative_start_policy: true. #1872 added the seller half. This PR adds the buyer half to ADCPClient.

On a session negotiated at 3.3 or later, the client checks its cached get_adcp_capabilities response. That is the same cache version negotiation already fills. When the seller doesn't declare the feature, the client returns a failed TaskResult before anything is sent:

result.success      # False
result.dispatched   # False: refused locally, nothing was sent
result.adcp_error == {
    "code": "UNSUPPORTED_FEATURE",
    "message": "Seller does not support: creative_start_policy",
    "field": "packages[1].start_requires_all_creatives_approved",
    "details": {
        "feature": "media_buy.features.creative_start_policy",
        "fields": ["packages[1].start_requires_all_creatives_approved", "packages[3].start_requires_all_creatives_approved"],
        "wire_response_received": False,
    },
}

The code, field, details.feature and details.fields match the seller's refusal from #1872. The client doesn't add mutation_outcome, because that is the seller's claim; dispatched=False already says nothing was sent. client.supports("creative_start_policy") answers the question before the call.

Not blocked

  • Capabilities unknown: a pinned server_version skips discovery, so the client sends and the seller decides. A conforming seller refuses with the same error.
  • false and omission: always sent.
  • Sessions below 3.3: unchanged. The field doesn't exist there, and version conversion refuses true exactly as it did before (a test pins this).

How

  • Precedent: this follows the existing version-preflight path. _convert_outgoing_request raises the typed ADCPFeatureUnsupportedError(dispatched=False) right after negotiating the target version. _prepare_versioned_task turns that into the failed TaskResult documented in docs/cross-version-conversion.md, and that result carries wire_response_received: false and the idempotency key.
  • ADCPFeatureUnsupportedError: gains optional field and details (additive), and _prepare_versioned_task copies them into adcp_error. Existing preflight errors don't set them, so their shape doesn't change. unsupported_features is ["creative_start_policy"], the same name client.supports() / client.require() use.
  • Module move: the shared helpers move from adcp.server._creative_start_policy to adcp._creative_start_policy, so the client doesn't import the server package. Both halves now live in one module.

Not marked breaking

The only requests now refused are ones AdCP 3.3 forbids buyers to send. A conforming seller, including this SDK's server since #1872, refuses them with the same code, and the client only refuses when it already knows the seller lacks the feature. The public change is additive: two optional keyword arguments. There is still a MIGRATION_v9_to_v10.md row because the behaviour is visible to users.

TypeScript parity

adcontextprotocol/adcp-client#3649 covers the server side only. I've added a comment there noting buyer-side parity as a follow-up.

Docs

  • docs/media-buy-action-rights.md: new "Creative start policy" subsection on buyer behaviour, including the unknown-capabilities case.
  • docs/adcp-3.3-sdk-inventory.md: the row now records the client half.
  • MIGRATION_v9_to_v10.md: new row.

Test plan

  • Buyer-side tests in tests/test_control_media_buy_creative_start_policy.py:
    • Refused when not declared: MCP and A2A, with no feature and with the feature set to false, across several packages; the wire is never called.
    • Allowed when declared: MCP and A2A.
    • Allowed when capabilities are unknown: MCP and A2A.
    • false and omission: always sent.
    • 3.2 session: unchanged.
    • End to end: a client that discovers a real in-memory MCP seller refuses without calling the seller.
  • The feat(server)!: refuse an undeclared creative start policy on control_media_buy #1872 MCP test now sends a raw tools/call, because the SDK client would refuse first. It pins the seller's full wire error, including mutation_outcome.
  • Neighbouring client preflight suites (test_version_client_boundaries, test_list_proposals_collections, test_control_request_intent, test_account_change_feed): 133 passed.
  • make typecheck-all
  • make lint
  • Full suite (local run in progress)

Closes #1876

🤖 Generated with Claude Code

… seller known not to declare it

AdCP 3.3 forbids a buyer from sending packages[].start_requires_all_creatives_approved:
true on control_media_buy to a seller without
media_buy.features.creative_start_policy: true. On a session negotiated at 3.3
or later, the client now checks its cached get_adcp_capabilities response (the
one version negotiation already uses) and, when the seller does not declare
the feature, returns a failed TaskResult before anything is sent:
dispatched=False, adcp_error.code UNSUPPORTED_FEATURE, adcp_error.field
packages[N].start_requires_all_creatives_approved for the first opted-in
package, and adcp_error.details with feature and every opted-in field, the
vocabulary the seller's own refusal uses (#1872).

A client that has not discovered the seller (a pinned server_version) sends
the request and lets the seller decide. false and omission always pass, and a
session below 3.3 behaves as before: version conversion refuses true there.

ADCPFeatureUnsupportedError gains optional field and details, which the
pre-send failed result carries. The shared helpers move from
adcp.server._creative_start_policy to adcp._creative_start_policy so the client
does not import the server package.

Closes #1876

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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 — buyer-side creative start policy gate (#1876), clean implementation with full test coverage.

What I checked:

  • No critical/high findings (row 1 clear).
  • gated_paths: false (row 2 n/a).
  • high_risk: true only from src/adcp/server/mcp_tools.py (modified), but with zero medium-or-higher findings the modification is presumed safe — row 5 requires a medium finding, which is absent. No (deleted) reasons (row 3 clear). No (added)-only concern.
  • No medium findings at all (rows 4, 8 clear).
  • No no-auto-approve team match (row 7 clear).
  • No prior decision (row 6 n/a).

The gate is correctly placed after capability discovery in _convert_outgoing_request, version-gated by is_adcp_version_at_least(target, "3.3-beta.0"), and the additive field/details on ADCPFeatureUnsupportedError are backward compatible (no semver break). The DR-0032/0033 "Refused" pathway is covered with tests across MCP/A2A, declared/undeclared/false/omitted, 3.2 sessions, and unknown capabilities — satisfying the mandatory pathway-plus-test rule.

Falls through to row 9 → approve.

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