feat(media-buy): reject get_products products without pricing_options#2269
feat(media-buy): reject get_products products without pricing_options#2269EmmaLouise2018 wants to merge 1 commit into
Conversation
pricing_options is a required, minItems:1 field in AdCP 3.1 — a product that advertises no pricing model is non-transactable. The client now drops such products from completed get_products responses before callers and completion handlers see them, on every completion path (sync, polling, track, webhook) and independent of the response validation mode. Recorded in result.metadata.productPricingPolicy and a product_missing_pricing_options debug-log notice. Default-on; opt out via validation.rejectProductsWithoutPricingOptions: false.
There was a problem hiding this comment.
Approving. Buyer-side enforcement of a spec-required field, wired at the one completion chokepoint so all five paths inherit it — right shape, and the disclosure (metadata + typed debug notice + opt-out) keeps it a witness, not a translator.
Premise checks out. ad-tech-protocol-expert confirmed pricing_options is in the Product schema's top-level required array and carries minItems: 1, identically in AdCP 3.0.0 and 3.1.0 (adcontextprotocol/adcp core/product.json on main and the v3.0.0 tag; corroborated by core.generated.ts:3748). So treating [] as equivalent to missing is spec-correct, and the cross-version worry is a non-issue: a 3.0 seller is held to the same constraint, and the v2.5 legacy layer types the field as required too. No supported wire version emits a spec-valid unpriced product this filter would wrongly drop.
Things I checked
- Chokepoint claim is true.
enforceProductPricingOptionsis the first statement inapplyProductPropertyPolicy, and every completion path routes through it: syncexecuteAndHandle(SingleAgentClient.ts:1696), pollingwaitForCompletion(1956),track→applyProductPropertyPolicyToTaskInfo(1941→1979), webhook (2031), secondexecuteTask(3101). Fires even with no property policy configured. matchaccessor survives the in-place mutation.attachMatchdefinesmatchnon-enumerably on theTaskResultobject, not onresult.data; reassigningresult.datato a spread clone while keepingresultidentity matches the existing property-policy filter path. Comment's claim is accurate.- Cast isn't trusted.
as unknown as GetProductsResponseis immediately re-read via(response as { products?: unknown }).productsand guarded withArray.isArray(...)+ length checks.products: undefined, empty array, all-unpriced, and all-priced are each handled and tested. - Webhook metadata forwarding is correct.
productPricingPolicyis spread ontoWebhookMetadataright besideproductPropertyPolicyinapplyProductPropertyPolicyToWebhookResult; the webhook test assertshandlerCalls[0].metadata.productPricingPolicy.rejected_count. - Changeset present (
reject-products-without-pricing-options.md,minor); fixture updates addpricing_optionsto four previously spec-invalid product fixtures — necessary to keep them transactable, not masking.code-reviewer: no Blocker/High/Medium.
Follow-ups (non-blocking — file as issues)
- Reconcile the default with
filterInvalidProducts. This is the second product-dropping toggle in thevalidationstruct, default-on, two lines below one (filterInvalidProducts,SingleAgentClient.ts:464-476) that defaults off. Bothad-tech-protocol-expertanddx-expertflagged the asymmetry: an adopter who reads the config learns the SDK's existing product-dropping behavior is opt-in, then gets a second one that isn't. Default-on is defensible here (the field is spec-required, the product is non-transactable) — but pick one discipline for buyer-side spec-conformance dropping and a one-line cross-reference in thefilterInvalidProductsJSDoc kills the surprise. - Changeset under-discloses the upgrade impact. The prose explains the mechanism thoroughly but never says the load-bearing sentence: adopters talking to non-strict/noncompliant sellers may receive fewer products after this
minorbump. Compliant sellers are unaffected; the slice that isn't gets a silent count drop with only atype: 'warning'debug log and a metadata field as breadcrumbs — passively discoverable, found only if you already suspect products went missing. Add the upgrade note + pointer toresult.metadata.productPricingPolicy.minoris borderline-but-acceptable with that sentence. - Track path filters but doesn't disclose.
applyProductPropertyPolicyToTaskInfo(SingleAgentClient.ts:1999-2000) drops the unpriced products viapolicyResult.data, butTaskInfohas no metadata/debug slot, soproductPricingPolicyand the notice are discarded on this one path. Pre-existing and consistent —productPropertyPolicyis dropped the same way — so the "all five paths" claim is true for filtering, not for disclosure. Worth one line in the PR body so it isn't read as full coverage.
Minor nits (non-blocking)
- Mode-independence inside a
validationblock reads slightly off.rejectProductsWithoutPricingOptionslives undervalidationbut the JSDoc has to clarify it ignoresvalidation.responses. Shared with its neighbors, so leave it — but ifvalidationkeeps accreting mode-independent product filters, a futureproductFilterssub-struct would read better.
LGTM. Follow-ups noted.
Summary
pricing_optionsis a required,minItems: 1field in AdCP 3.1 — a product that advertises no pricing model is non-transactable. The client now drops such products from completedget_productsresponses before callers and completion handlers see them.enforceProductPricingOptions()wired in at the top ofapplyProductPropertyPolicy()— the universal completion chokepoint — so it covers all five paths (syncexecuteAndHandle, pollingwaitForCompletion,track, webhook, and the secondexecuteTaskpath) with one integration point, and fires even when no property policy is configured.validationmode, so unpriced products are removed even underresponses: 'warn' | 'off'.matchaccessor.result.metadata.productPricingPolicyand aproduct_missing_pricing_optionsdebug-log notice; the webhook path forwards the same summary onWebhookMetadata.Config: default-on; opt out with
validation.rejectProductsWithoutPricingOptions: false.Why default-on
pricing_optionsis spec-required (minItems: 1), so a compliant seller never trips this. Returning an unpriced product to a buyer is a correctness bug. Bumpedminor, consistent with how the repo treats spec-conformance tightening (cf.fix(compliance): emit stripped field notices). Opt-out is provided for callers deliberately inspecting malformed seller responses.Changes
src/lib/core/SingleAgentClient.ts—enforceProductPricingOptions(), config option, helpers, wiring.src/lib/core/ConversationTypes.ts/src/lib/core/AsyncHandler.ts—productPricingPolicyonTaskResultMetadataandWebhookMetadata.test/lib/product-pricing-options-rejection.test.js— new: default drop, empty-array = missing, opt-out, all-priced no-op, webhook path.pricing_options).Testing
npm run typecheck— cleannpm run lint— 0 errorsminor)