Skip to content

feat: enforce the AdCP 3.3 creative start policy on control_media_buy - #3652

Merged
bokelley merged 4 commits into
mainfrom
feat/3649-creative-start-policy-enforcement
Nov 9, 2026
Merged

bokelley merged 4 commits into
mainfrom
feat/3649-creative-start-policy-enforcement

Conversation

@bokelley

@bokelley bokelley commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

AdCP 3.3 lets a buyer hold a package's first enablement for serving until every assigned creative is approved: packages[].start_requires_all_creatives_approved on control_media_buy. A seller that doesn't declare media_buy.features.creative_start_policy: true MUST reject true with UNSUPPORTED_FEATURE before mutation, and buyers MUST NOT send it. false is the default and always allowed.

Before this PR the SDK let true through on both sides. A seller that didn't know the field ignored it, and the buy started serving before its creatives were approved, which is the opposite of what the buyer asked for. This PR brings @adcp/sdk to parity with the Python SDK:

Closes #3649.

Server

When the server's adcpVersion is 3.3 or later, the createAdcpServer dispatcher checks control_media_buy. Handlers see requests in the server's release, so this matches Python's handler-version gate. The check runs:

  • whatever the request validation mode is;
  • after auth and account resolution;
  • before the idempotency lookup, so a cached response never answers an opted-in request and the refusal never touches the replay store;
  • before the handler.

Outcomes:

  • No package sets true: nothing happens.
  • A package sets true and the server's capabilities declare the feature: the request reaches the handler unchanged.
  • Otherwise the request fails, with the same fields and values as Python:
{
  "code": "UNSUPPORTED_FEATURE",
  "message": "start_requires_all_creatives_approved: true requires media_buy.features.creative_start_policy, which this seller does not declare",
  "recovery": "correctable",
  "field": "packages[1].start_requires_all_creatives_approved",
  "suggestion": "Omit start_requires_all_creatives_approved or send false; check media_buy.features.creative_start_policy in get_adcp_capabilities before opting in",
  "details": {
    "feature": "media_buy.features.creative_start_policy",
    "fields": ["packages[1].start_requires_all_creatives_approved", "packages[3].start_requires_all_creatives_approved"]
  },
  "mutation_outcome": "not_applied"
}

field names the first opted-in package and details.fields lists every one.

Where the declaration comes from. It's the server's own capabilities after overrides, the same object get_adcp_capabilities serves. Sellers declare it with the new typed capabilities.features.creativeStartPolicy: true, on createAdcpServer or createAdcpServerFromPlatform, or with capabilities.overrides.media_buy.features.creative_start_policy. Under createTenantRegistry each tenant has its own server, so each tenant's declaration decides.

mutation_outcome. TS never emitted it before. adcpError() now accepts mutation_outcome ('not_applied' | 'unknown' | 'applied', exported as AdcpMutationOutcome). The 3.2 error schema is open, so the field is safe when the request was served at an older release.

Not gated:

  • create_media_buy / update_media_buy. Withdrawing the field from their packages is pending fix(schema): keep 3.3 additions off the deprecated create_media_buy and update_media_buy (#8501) adcp#8767, a draft whose consent closes Nov 13. The pinned 3.3.0-beta.5 package-request and package-update schemas still declare the field with the same must-refuse contract. So until #8767 lands, a create or update that sets true reaches the handler unenforced. This is a known gap on the pinned beta, shared with adcp-client-python#1872. Enforcement there is deliberately not added, because it would be removed again once the GA-candidate beta drops the field. If #8767 fails consent, that becomes a follow-up.
  • Servers pinned below 3.3.

Buyer guard

SingleAgentClient (and so AgentClient) checks control_media_buy right after release negotiation, next to the existing target-version preflights. It refuses before sending when all of these hold:

  • a package sets true;
  • the call targets 3.3 or later (the negotiated release, else the per-call or configured wire version);
  • the cached capabilities are real, not synthetic;
  • resolveFeature(caps, 'creative_start_policy') is false. That's the same helper behind client.supports().

It throws ProtocolFeatureUnsupportedError, the existing class for client preflight refusals: code: 'UNSUPPORTED_FEATURE', dispatched: false, the same field, and details.feature / details.fields as the seller. getClientPreflightAdcpError(err) returns the AdCP error.

With synthetic or missing capabilities, the client sends and the seller decides. false and omission are always sent.

MediaBuyFeatures.creativeStartPolicy is parsed from the capabilities response, and client.supports('creative_start_policy') answers the question before the call.

Action resolver

mediaBuyActionResolver.resolve() follows the same rule. It offers update_creative_start_policy only when the new creativeStartPolicy: true option says the seller declares the feature. Otherwise it reports the action unavailable (not_supported_on_buy, certainty: 'blocked'), so the SDK never advertises an action its own gate would refuse. Unknown counts as a denial, matching the resolver's other seller gates. Sellers that already offer the action add the option, which the migration note covers.

Conformance follow-up

Storyboards drive sellers through the SDK client, so a future negative step that grades the seller's refusal would be pre-empted by the client guard. #3656 tracks a runner bypass for client-side seller preflights, linked to the Python equivalent. No storyboard in 3.3.0-beta.5 uses the field, so nothing is affected today.

Deviation from Python

Python returns a failed TaskResult with dispatched=False. TS throws, because every other TS client preflight refusal throws a FeatureUnsupportedError subclass and callers already catch those. The adcpError it carries has the same code, field and details vocabulary. Python's details.wire_response_received: false isn't copied: the TS error class already says dispatched: false.

Breaking for sellers

A seller that honored true without declaring the feature now has those requests refused. The changeset is minor because the repo is in beta pre-mode. docs/migration-16-to-17.md has a new "Creative start policy on control_media_buy" section.

Test plan

  • npm run typecheck
  • npm run lint, npm run lint:boundaries
  • Existing related suites (start-policy actions, capability parsing and overrides, decisioning capability projections, compact lifecycle release gate, storyboards and runner, server-create-adcp-server, MCP negotiation, validation diagnostics): 846 passed, 0 failed
  • New test/lib/control-media-buy-creative-start-policy.test.js, 23 passed:
    • Server: the exact refusal with the handler not called, both with no declaration and with creativeStartPolicy: false. Also: field / details.fields across several packages; refused under validation off, warn and strict; passes when declared via features and via overrides; false and omission reach the handler; a server pinned to 3.2.8 isn't gated; update_media_buy isn't gated; capabilities emit the feature only when declared.
    • Ordering vs. idempotency: a refusal is never cached (the same key later succeeds on a declaring server), and a cached success is never replayed to an undeclared server.
    • Parsing: parseCapabilitiesResponse / resolveFeature for true, false, absent and non-boolean values; adcpError carries mutation_outcome.
    • Client against a real serve() seller: refused locally (dispatched: false, handler never called, getClientPreflightAdcpError shape); sent when declared; false sent to an undeclared seller.
    • Client unit: synthetic capabilities send; discovered capabilities without the feature refuse and with it send; false and omission send; a client pinned to 3.2 sends.
  • test/lib/creative-start-policy-actions.test.js: the new "seller projection withholds the action unless the seller declares creative_start_policy" test covers the declaration omitted, false and 'true', a buyer request assessment while undeclared, and true. The existing projection test now declares the feature. 8 passed.
  • Manual end-to-end smoke for both halves
  • Known CI noise until fix(test): pin the request-signing 3.2-profile probes to the packaged release #3655 lands: the three inherited request-signing-applies-when 3.2-profile failures from main. feat: enforce the AdCP 3.3 creative start policy on control_media_buy #3652 will be rebased onto fix(test): pin the request-signing 3.2-profile probes to the packaged release #3655.

🤖 Generated with Claude Code

aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Nov 8, 2026

@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 — AdCP 3.3 creative-start-policy enforcement (#3649). Clean review with no blocking, medium, or load-bearing low findings.

Checked:

  • Witness-not-translator preserved: the gate refuses (seller returns UNSUPPORTED_FEATURE + mutation_outcome: 'not_applied'), never fabricates. Client preflights conservatively, refusing only on authoritative discovery (_synthetic === false) and sending on synthetic/unknown so the seller backstops. Fail-closed seller / fail-open-to-seller client is the correct shape.
  • Minor-release pathway: UNSUPPORTED_FEATURE carries no envelope allowlist so mutation_outcome survives (confirmed by test). create_media_buy/update_media_buy correctly excluded (field withdrawn in 3.3).
  • Changeset present and defensible: minor within the 17.0.0 beta pre-mode train.
  • No official-transport-client violations; no stale doc links.

Decision table: no critical/high/medium findings; gated_paths false; high_risk false; no team gate. Falls through to row 9.

@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 subsequent pass with no blocking or Medium findings.

This is a follow-up to a prior clean approve. The 134-line delta gates mediaBuyActionResolver's update_creative_start_policy action behind the new creativeStartPolicy: true capability option. Key points verified:

  • Fail-closed design: an unknown capability is treated as a denial, fed into request_assessments via the existing unavailable.find path — consistent with witness-not-translator (no fabrication or re-shaping).
  • Fully tested (two new test files covering the policy actions and control path).
  • Changeset present (creative-start-policy-enforcement.md), typed minor, consistent with a non-breaking feature add per DR-0033.
  • Documented in migration-16-to-17.md.

Gate checks:

  • No no-auto-approve team match.
  • gated_paths: false (row 2 does not fire despite review_decision: REVIEW_REQUIRED).
  • high_risk: false; no deletions/modifications triggers.
  • Reviewer surfaced zero findings.

None of rows 1–8 fire, so this falls through to row 9 → approve.

bokelley and others added 3 commits November 8, 2026 23:25
A seller whose capabilities do not declare
media_buy.features.creative_start_policy: true now refuses
packages[].start_requires_all_creatives_approved: true on
control_media_buy with UNSUPPORTED_FEATURE before the handler runs and
before the idempotency lookup, matching adcp-client-python#1872. The
client refuses the same request locally when its discovered
capabilities for a 3.3 call lack the feature, matching
adcp-client-python#1878.

Adds MediaBuyFeatures.creativeStartPolicy (server declaration and
parsed client capability) and mutation_outcome on adcpError().

Refs #3649

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Server refusal shape, multi-package field reporting, every validation
mode, declaration via features and overrides, release and task scoping,
ordering before idempotency, capability emission, client preflight
against a real seller, and synthetic/sub-3.3 pass-through.

Refs #3649

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

mediaBuyActionResolver.resolve() takes creativeStartPolicy and offers
update_creative_start_policy only when it is true; otherwise it reports
the action unavailable (not_supported_on_buy), so the SDK never
advertises an action its own control_media_buy gate refuses.

Refs #3649

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bokelley
bokelley force-pushed the feat/3649-creative-start-policy-enforcement branch from 06cf92b to e5e542a Compare November 8, 2026 23:31
aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Nov 8, 2026

@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 pass on the full 12-file diff.

Checked: the AdCP 3.3 creative-start-policy gate is correct and fail-closed on both halves. The server refuses opted-in control_media_buy packages before the idempotency lookup (create-adcp-server.ts:7750), and the client refuses pre-send only when discovery is authoritative (_synthetic === false), correctly sending on synthetic/missing caps. mutation_outcome survives because UNSUPPORTED_FEATURE has no ADCP_ERROR_FIELD_ALLOWLIST entry — witness-not-translator preserved (refusals are witness-preserving, no fabrication). Official transport clients untouched. Changeset present; its minor type is defensible for a documented seller-break under the experimental-surface/beta downgrade. Docs updated. Tests comprehensive (server/client/action-resolver/idempotency ordering).

No critical/high/medium findings. Prior decision was also approve; this run confirms it. high_risk false, gated_paths false, review_decision APPROVED, no team gate. Falls through to row 9 → approve.

The pinned 3.3.0-beta.5 package-request and package-update schemas
still declare start_requires_all_creatives_approved with the
must-refuse contract; its withdrawal is the pending adcp#8767. State
that create/update requests with true reach the handler unenforced
until then, a known gap shared with the Python SDK.

Refs #3649

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 — doc/comment/changeset-only delta on a previously-approved PR, no blocking findings.

This subsequent pass changes 3 files / 51 lines: the changeset, docs/migration-16-to-17.md, and the header comment in src/lib/media-buy/creative-start-policy.ts. The correction replaces the inaccurate claim that AdCP 3.3 "withdraws" start_requires_all_creatives_approved with the accurate statement that the pinned 3.3.0-beta.5 package-request/package-update schemas still declare it with a must-refuse rule, noting the known unenforced-handler gap (tracked at adcontextprotocol/adcp#8767). Code behavior is unchanged from the prior approved pass; the edit moves docs toward accuracy and names the pathway/tracking issue.

Decision trace (top-down): no critical/high findings (row 1 n/a); gated_paths is false (row 2 n/a); high_risk false, no deletions (rows 3/5 n/a); no medium findings at all (rows 4/8 n/a); prior decision was approve, not escalate (row 6 n/a); no no-auto-approve team match (row 7 n/a). Falls through to row 9 → approve.

No blocking findings. No medium findings.

@bokelley
bokelley merged commit 7a812a1 into main Nov 9, 2026
45 checks passed
@bokelley
bokelley deleted the feat/3649-creative-start-policy-enforcement branch November 9, 2026 00:54
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.

Server: refuse start_requires_all_creatives_approved: true on control_media_buy when the seller doesn't declare creative_start_policy

1 participant