Repository navigation
feat: enforce the AdCP 3.3 creative start policy on control_media_buy - #3652
Conversation
There was a problem hiding this comment.
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_FEATUREcarries no envelope allowlist somutation_outcomesurvives (confirmed by test).create_media_buy/update_media_buycorrectly excluded (field withdrawn in 3.3). - Changeset present and defensible:
minorwithin 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.
There was a problem hiding this comment.
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_assessmentsvia the existingunavailable.findpath — 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), typedminor, 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 despitereview_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.
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>
06cf92b to
e5e542a
Compare
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
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_approvedoncontrol_media_buy. A seller that doesn't declaremedia_buy.features.creative_start_policy: trueMUST rejecttruewithUNSUPPORTED_FEATUREbefore mutation, and buyers MUST NOT send it.falseis the default and always allowed.Before this PR the SDK let
truethrough 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/sdkto parity with the Python SDK:Closes #3649.
Server
When the server's
adcpVersionis 3.3 or later, thecreateAdcpServerdispatcher checkscontrol_media_buy. Handlers see requests in the server's release, so this matches Python's handler-version gate. The check runs:Outcomes:
true: nothing happens.trueand the server's capabilities declare the feature: the request reaches the handler unchanged.{ "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" }fieldnames the first opted-in package anddetails.fieldslists every one.Where the declaration comes from. It's the server's own capabilities after overrides, the same object
get_adcp_capabilitiesserves. Sellers declare it with the new typedcapabilities.features.creativeStartPolicy: true, oncreateAdcpServerorcreateAdcpServerFromPlatform, or withcapabilities.overrides.media_buy.features.creative_start_policy. UndercreateTenantRegistryeach tenant has its own server, so each tenant's declaration decides.mutation_outcome. TS never emitted it before.adcpError()now acceptsmutation_outcome('not_applied' | 'unknown' | 'applied', exported asAdcpMutationOutcome). 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 pinned3.3.0-beta.5package-requestandpackage-updateschemas still declare the field with the same must-refuse contract. So until #8767 lands, a create or update that setstruereaches 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.Buyer guard
SingleAgentClient(and soAgentClient) checkscontrol_media_buyright after release negotiation, next to the existing target-version preflights. It refuses before sending when all of these hold:true;resolveFeature(caps, 'creative_start_policy')is false. That's the same helper behindclient.supports().It throws
ProtocolFeatureUnsupportedError, the existing class for client preflight refusals:code: 'UNSUPPORTED_FEATURE',dispatched: false, the samefield, anddetails.feature/details.fieldsas the seller.getClientPreflightAdcpError(err)returns the AdCP error.With synthetic or missing capabilities, the client sends and the seller decides.
falseand omission are always sent.MediaBuyFeatures.creativeStartPolicyis parsed from the capabilities response, andclient.supports('creative_start_policy')answers the question before the call.Action resolver
mediaBuyActionResolver.resolve()follows the same rule. It offersupdate_creative_start_policyonly when the newcreativeStartPolicy: trueoption 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
TaskResultwithdispatched=False. TS throws, because every other TS client preflight refusal throws aFeatureUnsupportedErrorsubclass and callers already catch those. TheadcpErrorit carries has the same code, field and details vocabulary. Python'sdetails.wire_response_received: falseisn't copied: the TS error class already saysdispatched: false.Breaking for sellers
A seller that honored
truewithout declaring the feature now has those requests refused. The changeset isminorbecause the repo is in beta pre-mode.docs/migration-16-to-17.mdhas a new "Creative start policy oncontrol_media_buy" section.Test plan
npm run typechecknpm run lint,npm run lint:boundariesserver-create-adcp-server, MCP negotiation, validation diagnostics): 846 passed, 0 failedtest/lib/control-media-buy-creative-start-policy.test.js, 23 passed:creativeStartPolicy: false. Also:field/details.fieldsacross several packages; refused under validationoff,warnandstrict; passes when declared viafeaturesand viaoverrides;falseand omission reach the handler; a server pinned to 3.2.8 isn't gated;update_media_buyisn't gated; capabilities emit the feature only when declared.parseCapabilitiesResponse/resolveFeaturefor true, false, absent and non-boolean values;adcpErrorcarriesmutation_outcome.serve()seller: refused locally (dispatched: false, handler never called,getClientPreflightAdcpErrorshape); sent when declared;falsesent to an undeclared seller.falseand 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,falseand'true', a buyer request assessment while undeclared, andtrue. The existing projection test now declares the feature. 8 passed.request-signing-applies-when3.2-profile failures frommain. 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