Skip to content

feat(decisioning): expose AdcpCapabilitiesConfig passthrough slots on DecisioningCapabilities (#2199)#2201

Merged
bokelley merged 1 commit into
adcontextprotocol:mainfrom
kapoost:feat/decisioning-capabilities-passthrough
Jun 13, 2026
Merged

feat(decisioning): expose AdcpCapabilitiesConfig passthrough slots on DecisioningCapabilities (#2199)#2201
bokelley merged 1 commit into
adcontextprotocol:mainfrom
kapoost:feat/decisioning-capabilities-passthrough

Conversation

@kapoost

@kapoost kapoost commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Closes #2199.

Summary

Adds optional passthrough fields to DecisioningCapabilities<TConfig> so adopters using definePlatform / createAdcpServerFromPlatform can declare capability details that were already modeled by the lower-level AdcpCapabilitiesConfig surface:

  • features?: Partial<MediaBuyFeatures>
  • creative?: Partial<CreativeCapabilities>
  • account?: Partial<AccountCapabilities>
  • overrides?: AdcpCapabilitiesOverrides
  • supported_versions?: string[]

This is the Option A shape from the #2199 triage: one declaration point on the platform, with framework-derived projections still authoritative for fields the framework owns.

Notes

  • features is forwarded as the base media_buy.features config; rich framework projections such as audience_targeting, conversion_tracking, and content_standards still force their matching feature booleans to true.
  • account is forwarded as the base account block; existing requireOperatorAuth and supportedBillings projections still overlay that base.
  • overrides are merged before framework-derived per-domain blocks, preserving existing framework precedence.
  • supported_versions is now emitted on get_adcp_capabilities.adcp.supported_versions; previously it was only used for version-error details.

Validation

  • npm run build:lib
  • npm run typecheck
  • npm audit --audit-level=high
  • npx prettier --check src/lib/server/create-adcp-server.ts test/server-decisioning-capability-projections.test.js src/lib/server/decisioning/capabilities.ts src/lib/server/decisioning/runtime/from-platform.ts .changeset/2199-decisioning-capabilities-passthrough.md
  • NODE_ENV=test node --test --test-timeout=60000 test/server-decisioning-capability-projections.test.js

@aao-ipr-bot

aao-ipr-bot Bot commented Jun 8, 2026

Copy link
Copy Markdown

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

@bokelley bokelley force-pushed the feat/decisioning-capabilities-passthrough branch from 2320431 to 981b219 Compare June 13, 2026 10:31

@aao-ipr-bot aao-ipr-bot 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.

Right shape on the type surface and the wire output — ad-tech-protocol-expert confirms adcp.supported_versions is a defined string[] member of the capabilities schema and that forwarding the adopter's own self-declaration is not a witness-invariant breach (no upstream is being relayed). But the per-domain override merge ships shallow where the JSDoc and changeset both promise deep-merge, and that silently drops adopter-declared wire data. One fix needed before this is safe.

MUST FIX

  1. src/lib/server/decisioning/runtime/from-platform.ts:1174-1180 — shallow merge silently drops adopter override keys under media_buy.execution. The new construction does a two-level shallow spread:

    media_buy: { ...(adopterOverrides?.media_buy ?? {}), ...mediaBuyOverrides }

    mediaBuyOverrides carries a nested execution: { targeting } key (line 1034) whenever the framework derives targeting and hasMediaBuyProjection is true. When an adopter declares both capabilities.targeting and overrides.media_buy.execution with sibling keys (execution.frequency, additional execution.targeting.*), the spread replaces the adopter's entire execution object with the framework's { targeting } — the adopter's sibling keys are dropped from media_buy.execution on the wire. The downstream applyCapabilityOverrides (create-adcp-server.ts:1858) is a recursive deep merge, but it runs after the loss has already happened at construction time, so it can't recover it. This contradicts the PR's own documented contract — the overrides JSDoc and changeset both call it a "deep-merge passthrough" where "framework-derived values remain authoritative on the keys the projection engine handles," which implies non-overlapping adopter keys survive. They don't. Deep-merge the per-domain blocks (reuse a recursive merge), or at minimum merge execution explicitly. Same shallow shape applies to the brand and account blocks; smaller blast radius today because those projections emit flat keys, but the seam is wrong everywhere.

Things I checked

  • Imports resolve. MediaBuyFeatures / AccountCapabilities / CreativeCapabilities (utils/capabilities.ts:31/54/97) and AdcpCapabilitiesOverrides (create-adcp-server.ts:1055) all export; Partial<> wrapping matches the existing override-type convention.
  • supported_versions emit at create-adcp-server.ts:6143 is clean. ...(capConfig?.supported_versions?.length && {...}) suppresses the empty array, spreading a falsy value into an object literal is a safe no-op, and the schema (tools.generated.ts) allows adcp.supported_versions?: string[]. Defensive copy ([...]) is consistent with the file.
  • features precedence is correct. Base laid from capConfig.features (create-adcp-server.ts:6149-6160), applyCapabilityOverrides overlays the per-domain media_buy block after (:6196), so framework-derived audience_targeting/conversion_tracking/content_standards win on those three keys and adopter-only keys survive. The new test proves it: adopter sets audienceTargeting: false, framework forces true, wire result is true.
  • Gating is sound. hasAdopterPassthroughs / hasOverridesObject include all five new fields in the emit condition; no declared passthrough is dropped at the gate. The only silent drop is the nested-merge loss above, not a gating miss.
  • Changeset: minor is correct for five new optional non-breaking fields. Descriptively named, not the random-slug form.

Follow-ups (non-blocking — file as issues)

  • media_buy: null delete-sentinel is neutralized when a projection fires. AdcpCapabilitiesOverrides.media_buy is typed ... | null and applyCapabilityOverrides treats null as "delete this block." The ...(adopterOverrides?.media_buy ?? {}) coalesces the null away and the explicit media_buy: key overwrites it whenever hasMediaBuyProjection is true, so an adopter passing media_buy: null gets the block re-emitted. Lower likelihood than the execution case — fix alongside #1 or call out as out-of-scope.
  • No format validation on supported_versions. An adopter can pass a full-semver "3.1.0-beta.2" (a meta-field value the schema docstring explicitly says is not a valid wire value and must normalize to "3.1-beta.2"); the PR forwards verbatim and string[] response validation won't catch it. A normalization assertion or a doc pointer would harden it.

Minor nits (non-blocking)

  1. The new test doesn't exercise the collision path. test/server-decisioning-capability-projections.test.js:247-280 sets overrides.media_buy.execution.targeting.geo_countries but never sets capabilities.targeting, so mediaBuyOverrides has no execution key and the shallow merge never collides — the MUST FIX bug passes this test green. Add a case that declares both capabilities.targeting and an overrides.media_buy.execution sibling key, then assert both survive.

Fix #1 and add the collision test. The fix is contained and the rest of the PR is right — happy to re-approve once the merge is deep at that seam.

Closes adcontextprotocol#2199.

Expose optional capability passthrough slots on DecisioningCapabilities.

Fields: features, creative, account, overrides, and supported_versions.

Also emit supported_versions on get_adcp_capabilities.adcp.

Add a regression for the full definePlatform projection path.
@bokelley bokelley force-pushed the feat/decisioning-capabilities-passthrough branch from 981b219 to 5acfd7d Compare June 13, 2026 10:40

@aao-ipr-bot aao-ipr-bot 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.

Approving. Additive optional fields on DecisioningCapabilities that close a real definePlatform reachability gap — the high-level v6 surface now reaches the AdcpCapabilitiesConfig slice the low-level createAdcpServer API always exposed. Framework projections stay authoritative on the keys they own; adopter declarations sit underneath. Right shape.

Things I checked

  • adcp.supported_versions is on the wire, not fabricated. ad-tech-protocol-expert: sound — the field is spec-defined at tools.generated.ts (supported_versions?: string[] in the adcp block, provenance adcp#3493), with major_versions deprecated in its favor. The buyer side already reads adcp.supported_versions (SingleAgentClient.ts, utils/capabilities.ts); this closes the symmetric seller-emit gap. Witness, not translator.
  • Release-precision shape is correct. Test ships ['3.1'], example ships ["3.0","3.1"] — matches the schema's release-precision contract; full semver lives on the separate build_version field.
  • Deep-merge precedence is correct and test-covered. from-platform.ts: ...(adopterOverrides ?? {}) lays the adopter base, then mergeCapabilityOverride(adopterOverrides?.media_buy, mediaBuyOverrides) deep-merges framework on top per domain. The test proves it: adopter's keyword_targets.supported_match_types: ['exact'] survives alongside framework-projected geo_countries: true, and auto-derived audience_targeting: true correctly overlays the passthrough audienceTargeting: false base.
  • supported_versions empty/undefined spread is safe. create-adcp-server.ts:6144...(capConfig?.supported_versions?.length && {...}) no-ops on undefined and on [] ({...0} spreads to nothing). No emit of empty [], no crash.
  • Changeset present and correctly typedminor, additive optional fields, non-breaking. code-reviewer: clean on category. Changeset-vs-wire audit: no removed exports, no param flips, new optional fields only → minor is right.
  • Type surface is realAdcpCapabilitiesConfig.supported_versions, overrides, and the exported AdcpCapabilitiesOverrides all exist; the capabilities.ts import resolves.

Follow-ups (non-blocking — file as issues)

  • compliance_testing is the one override block not deep-merged. from-platform.ts:1178-1179 clobbers adopterOverrides.compliance_testing wholesale instead of mergeCapabilityOverride-ing it like media_buy/brand/account. code-reviewer confirmed this doesn't lose real intent — the wire compliance_testing block has one field (scenarios), and the adopter's real path is capabilities.compliance_testing.scenariosdeclaredCT, which is honored. But the asymmetry reads like an oversight. Either use mergeCapabilityOverride for symmetry or add a one-line comment explaining why the single-field block is exempt.
  • isPlainObject / deepMergePlainObjects are byte-for-byte duplicates of the same helpers in create-adcp-server.ts:1870-1892. Two copies of merge semantics drift. Extract to a shared util.
  • supported_versions is forwarded verbatim, not normalized. The schema docs warn SDKs reading full-semver from bundle metadata MUST normalize to release-precision before emitting. A normalizeToRelease() pass (or a footgun comment) on this slot would harden it, matching the buyer-side normalization the SDK already documents.

Minor nits (non-blocking)

  1. Spread idiom is the odd one out. create-adcp-server.ts:6144 uses ...(x?.length && {}) where the surrounding capability blocks (6184, 6192) use imperative if (capConfig?.x?.length) { ... }. Behavior is identical; matching the local idiom reads clearer.
  2. supported_versions edge cases aren't test-covered. The happy path and the audience_targeting precedence are locked in; an empty-array assertion would pin the no-emit behavior so a future refactor can't regress it to [].

LGTM. Follow-ups noted below.

@bokelley bokelley merged commit 49db972 into adcontextprotocol:main Jun 13, 2026
30 checks passed
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.

definePlatform: expose features / creative / account / overrides / supported_versions passthrough slots

2 participants