feat(media-buy): reject get_products products without pricing_options#2269
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.
…a-v2 # Conflicts: # package-lock.json
bokelley
left a comment
There was a problem hiding this comment.
Reviewed the updated branch after clearing CI blockers and merging current main. Local validation: npm run format:check, npm audit --audit-level=high, npm run build:lib, and NODE_ENV=test node --test-timeout=60000 --test-force-exit --test test/lib/product-pricing-options-rejection.test.js test/lib/agent-client-in-process.test.js test/lib/product-property-policy-client.test.js test/lib/v2-getproducts-autowire.test.js all pass. The remaining npm audit item is the existing moderate js-yaml advisory below the CI high-severity gate.
There was a problem hiding this comment.
Mechanically clean, well-tested, and the single-chokepoint wiring is the right call — but it ships a new default-on behavior I want the author on record about before it merges. The principle in tension is witness, not translator: the SDK now silently removes wire data by default, on a path the caller can't see unless they read result.metadata.productPricingPolicy.
Things I checked
- All five completion paths covered.
enforceProductPricingOptionsis the first statement inapplyProductPropertyPolicy(src/lib/core/SingleAgentClient.ts:1747), the shared chokepoint for sync (:1696), secondexecuteTask/track(:3101), polling (:1956), and webhook (:2031). One integration point, correct. - Null/cast safety.
productHasPricingOptions/productIdForPricingDiagnosticsboth guard!product || typeof product !== 'object'before access and narrowproduct_idwithtypeof id === 'string'. No deref risk. - Mutation ordering is safe. Pricing enforcement reassigns
result.dataand metadata first; the property-policy block then re-readsresult.datainto a freshresponseconst (:1728) and mutatesresult.metadata.productPropertyPolicyas a key-add (:1851), soproductPricingPolicysurvives when both policies are active. Webhook forwarder carries both keys independently. result.data = {...response, products: kept} as Tmirrors the existing property-policy filter at:1870-1875— same spread, same cast, sound on theget_products-guarded path.- Changeset present,
minor. Defensible — additive config + spec-conformance tightening, consistent withfix(compliance): emit stripped field notices. No removed/renamed export, no param flip. - Tests cover default drop, empty-array-as-missing, opt-out, all-priced no-op, and the webhook path. They assert observable behavior, not internals. Three fixtures correctly backfilled with
pricing_options. (Could not run the suite locally — sandbox blocks it; PR body reports 12034 pass.)
Open question — what flips this to approve
Two experts ran in parallel. code-reviewer: "ready to push." ad-tech-protocol-expert: "sound-with-caveats, leaning unsound on the default-on shape." The divergence is the review.
-
minItems: 1is unverified, and the empty-array branch may over-reject. The SDK's own generated schema ispricing_options: z.array(PricingOptionSchema)(src/lib/types/schemas.generated.ts:9331) — required, but no.min(1), and the codegen emits zero array.min()anywhere, so it's silent on whether[]is valid. The PR body and changeset both assertminItems: 1. If upstreamproduct.jsononly markspricing_optionsrequired (notminItems: 1), then droppingpricing_options: []removes a schema-valid product. Confirm againstadcontextprotocol/adcpproduct.jsonand cite it —spec → mock → SDK, per CLAUDE.md. (Dropping[]is semantically defensible — zero pricing models is non-transactable either way — but the changeset shouldn't claim a constraint we can't point to.) -
Default-on, validation-mode-independent silent removal is a new shape. The closest precedent,
filterInvalidProducts, is@default falseand mode-gated (SingleAgentClient.ts:474).productPropertyPolicyis not precedent here — it filters on buyer authorization, is buyer-configured, and defaults to inert. This PR is the first default-on removal that fires even underresponses: 'off'. A witness surfaces seller non-conformance as a notice and lets the caller decide; it doesn't delete the row by default. Two viable shapes that would flip me to approve: (a) flip the default tofalse, keeping the detection +productPricingPolicymetadata + debug notice as the default behavior; or (b) fold this intofilterInvalidProductsas one more validity check, so there aren't two parallel product-removal mechanisms with opposite defaults. If you keep it default-on, make the spec-required case (minItems: 1confirmed) the explicit, documented justification.
Follow-ups (non-blocking)
productPricingPolicy.okis dead (ConversationTypes.ts) — hardcodedtrue, neverfalse. Drop it or document the shape-parity intent.trackpath doesn't surface the summary.applyProductPropertyPolicyToTaskInforeturns{...taskInfo, result: policyResult.data}and discardspolicyResult.metadata/debug_logs(pre-existingTaskInfolimitation, shared withproductPropertyPolicy). Products are dropped but the PR body's "recorded inresult.metadata.productPricingPolicy" doesn't hold ontrackspecifically. Tighten the claim or the path.- PR is
CONFLICTING/DIRTYagainst current main (lockfile shows9.0.0; main is9.5.0). Rebase before merge — the undici/lockfile delta is base-staleness, not an intended change.
Resolve #1 (cite the spec) and answer #2 (the default shape) and I'll approve.
There was a problem hiding this comment.
Clean, contained change wired at the one right chokepoint. enforceProductPricingOptions() lands at the top of applyProductPropertyPolicy() (src/lib/core/SingleAgentClient.ts), so it covers all five completion paths — sync, polling, track, webhook, second executeTask — with a single integration point and fires even when no property policy is configured. Fail-closed on a non-transactable product beats fail-open. Spec premise verified: pricing_options is required, minItems: 1 in AdCP 3.1 (tools.generated.ts:1453, schemas.generated.ts:9331), so a compliant seller never trips this.
Things I checked
- Path coverage. Wired before the
policyConfig === falseearly-return, so enforcement runs independent of property-policy config and of the responsevalidationmode. Polling/track route throughapplyProductPropertyPolicyToTaskInfo/waitForCompletion, webhook throughapplyProductPropertyPolicyToWebhookResult— all call the chokepoint. Confirmed. matchaccessor survival. Theresult.data = {...response, products: kept}reassignment is safe —matchis attached non-enumerably toresult, notresult.data(match.ts:101-108), andmatch()readsthis.dataat call time so it reflects the filtered list. Identical to the existing property-policy filter path (SingleAgentClient.ts:1909-1913).- Ordering. Pricing enforcement reassigns
result.datafirst; the property policy then re-readsconst response = result.data(:1767) and filters the already-pricing-filtered survivors. Property policy setsmetadata.productPropertyPolicyby direct assignment, so it doesn't clobber theproductPricingPolicykey. No interaction bug. - Empty-array vs undefined.
productHasPricingOptionsrequiresArray.isArray(options) && options.length > 0— both[]andundefinedcount as missing. Matches theminItems: 1spec. Test covers it. - Webhook forwarding.
productPricingPolicyis forwarded ontoWebhookMetadata(:2199-2200, type inAsyncHandler.ts). Test asserts the webhook handler sees the filtered list and the summary. - Changeset present (
.changeset/reject-products-without-pricing-options.md), descriptively named, opt-out documented. Three fixtures correctly backfilled with spec-requiredpricing_options.
Follow-ups (non-blocking — file as issues)
minorvsmajoris a real tension — confirm with the maintainer.ad-tech-protocol-expert: this is the first SDK path that silently drops items from a public method's response by default with no buyer request driving it. A caller inspecting a non-compliant seller receives fewer products than before with no code change. The PR cites spec-conformance-tightening precedent forminorand the dropped data is spec-invalid with an opt-out, which is why I'm not blocking — but the bump is the one judgment call here. If there's any doubt,majoris the conservative read.- Default-on diverges from the sibling.
filterInvalidProductsdefaults tofalse(opt-in,:495); this defaults totrue. The PR body justifies default-on deliberately, so it's a choice, not an oversight — but two adjacent product-filtering knobs with opposite defaults is a DX trap worth reconciling. Consider whether a flag-and-keep middle mode (matchingproductPropertyPolicy's three modes) would preserve the witness posture better than a binary drop. - Wire-version / legacy-seller interaction. Enforcement keys only on
taskType === 'get_products'with no version gate.normalizeResponseToV3runs first on the legacy/poll paths, but confirm a pre-3.1 pin or legacy seller whose products legitimately predate requiredpricing_optionsdoesn't get every product nuked by default. Cross-checkdocs/development/WIRE-VERSION-COMPAT.md. taskInfopath drops the summary.applyProductPropertyPolicyToTaskInfo(:2038-2039) propagates onlypolicyResult.databack ontotaskInfo.result;productPricingPolicyis discarded becauseTaskInfohas no metadata channel. Filtering still happens — this mirrors howproductPropertyPolicy's summary already behaves on that path — but the PR-body claim that the outcome is recorded "on every completion path" isn't literally true there. Tighten the prose or add a filtering assertion for that path.
Minor nits (non-blocking)
- The "mutate in place" comment doesn't mutate in place.
SingleAgentClient.ts— the comment says "Mutate in place ... so the non-enumerablematchaccessor ... survives," but the code spread-replacesresult.datawith a fresh object. The accessor survives because it lives onresult, not because of in-place mutation. Effect is correct and matches the existing filter path; the rationale in the comment is the part that's off. type: 'warning'vs "debug-log notice." The emitted log entry istype: 'warning'while the docs/changeset call it a "debug-log notice." Consistent with theinput_schema_field_strippedprecedent, so leave it — just a prose/label mismatch.
LGTM. Follow-ups noted below — the minor/major call is the one I'd want a maintainer eye on before this ships.
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)