Repository navigation
feat(server): seller helper for wholesale feed versions - #1706
Conversation
Add adcp.server.feed_version, the seller half of conditional wholesale get_products / get_signals reads (the buyer half is adcp.feed_mirror): - wholesale_feed_version(items, *, cache_scope, ignore=FRESHNESS_FIELDS, extra=None): order-independent SHA-256 over RFC 8785 canonical item JSON, keyed by cache_scope, ignoring forecast generated_at by path. - pricing_version(items, *, cache_scope): the pricing layer (pricing_options and signal_targeting_options[].pricing_options), bound to item identity. - check_feed_preconditions(request): if_* tokens only on wholesale reads, if_pricing_version only beside if_wholesale_feed_version, raising AdcpError(INVALID_REQUEST, field=...). - feed_unchanged(request, *, feed_version, pricing_version=None): the spec's evaluation order. products_response / signals_response no longer emit unchanged: false and reject an unchanged: true answer that carries items or omits the version. Closes #1676 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve.
Reviewed the new seller helper module adcp.server.feed_version (6 additive public exports, correctly shipped under feat:). The token logic sorts per-item digests so ordering is ignored while duplicate ids still move the token (feed_version.py:331-333); feed_unchanged fails closed on continuation pages and on separated-pricing without if_pricing_version (L445-454); _mode has no fail-open accept path (L472-481). The responses.py change (dropping unchanged:false, guarding unchanged:true — L562-578, L604/L627) is a corrective fix of a previously schema-invalid payload rather than a semver break, and existing callers (test_server_dx.py:136,:719) still satisfy the new guard. Pure-hashing module with no I/O. New tests accompany the change.
high_risk is true but the only matching entries are additive: feed_version.py (added) is new scaffolding, and the two (modified) files carry no medium-or-higher findings, so the modification is presumed safe. gated_paths is false; no author team gate. No critical, high, or medium findings — decision table falls through to row 9.
Backport of #1706 to SDK 9.x (AdCP 3.2), whose get_products / get_signals wholesale fields, if_wholesale_feed_version / if_pricing_version rules and unchanged: true response are the same as 3.3's. adcp.server gains wholesale_feed_version and pricing_version (RFC 8785 canonical, SHA-256, order-independent across items, path-addressed freshness fields ignored, keyed on cache_scope), check_feed_preconditions (the mode and pairing rules, INVALID_REQUEST with field) and feed_unchanged. products_response / signals_response no longer write unchanged: false, and refuse unchanged=True with items or without wholesale_feed_version, both of which the schema already rejects. Differences from #1706: the extra= docs no longer name the 3.3-only collections / signal_pricing_group_rules, and one test compares a model with its own wire form, since SDK 9 dumps unset schema defaults. Refs #1676 Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Gap
The SDK has the buyer half of conditional wholesale reads:
adcp.feed_mirrorsendsif_wholesale_feed_version/if_pricing_versionand honoursunchanged: true. It also has theWholesaleFeedWebhooksender. It has no seller half. A seller serving wholesaleget_products/get_signalshas to hand-roll three things: a content token, the request rules for theif_*tokens (assert_buying_mode_consistentchecksbriefandrefineonly), and the comparator plus theunchanged: trueanswer.API
New module
adcp.server.feed_version, exported fromadcp.server:wholesale_feed_version(items, *, cache_scope, ignore=FRESHNESS_FIELDS, extra=None) -> strpricing_version(items, *, cache_scope) -> strcheck_feed_preconditions(request)AdcpError("INVALID_REQUEST", field=...)when theif_*rules are brokenfeed_unchanged(request, *, feed_version, pricing_version=None) -> boolFRESHNESS_FIELDS,PRICING_FIELDSThe
unchanged: trueanswer uses the existingunchanged=option onproducts_response/signals_response, which is tightened:unchanged=Falseis no longer emitted; the spec forbidsunchanged: false.unchanged=Truetogether with items, or withoutwholesale_feed_version, raisesValueError. That payload was already schema-invalid.cache_scopeis still not required by the builder, because the MCP dispatcher fills inpublicfor requests without an account.Docs:
docs/wholesale-feed-versions.md.Design decisions
products_response/signals_responseputs it on the wire (models viamodel_dump(mode="json"),Nonestripped from dicts, the same scrubber). It is then RFC 8785 canonicalized with therfc8785package the SDK already uses, and hashed with SHA-256. The token is the SHA-256 of{cache_scope, sorted item digests, extra?}, formatted assha256:<64 hex>. The schema has no pattern or length constraint on the token.product_id/signal_agent_segment_id, which is also howFeedMirrorindexes it. Arrays inside an item keep their order: some are ordered by contract (forecast.pointsascend by budget), and the schema declares no set semantics for the rest. A reorder costs at most one spurious refetch, while a false "unchanged" would leave the buyer with stale data.ignoretakes dot paths from the item root, and arrays are traversed without a path segment. Bare key names are never matched anywhere in the item. The defaultFRESHNESS_FIELDSisforecast.generated_atandcoverage_forecast.generated_at, the only computation timestamps in the product and signal item schemas.valid_untilis kept because buyers read it for expiry.cache_scopeis hashed into the token. The spec keys the comparator on(cache_scope, wholesale_feed_version), and the request does not carry the scope the buyer cached under, so the token itself has to carry it.extra. This is for response-level content outside the item list that the buyer sees:collectionswheninclude_collectionsis set (the spec requires distinct tokens for the two settings), andsignal_pricing_group_rules.PRICING_FIELDSispricing_optionsandsignal_targeting_options.pricing_options, the fields the schema text ofGetProductsResponse.pricing_versionnames. Each price is bound to its item's ID, so swapping prices between items counts as a change. A seller that separates pricing callswholesale_feed_version(..., ignore=FRESHNESS_FIELDS | PRICING_FIELDS).feed_unchangedfollows the spec's evaluation order: a feed mismatch means a full payload, a feed match with a pricing mismatch means a full payload, and both matching meansunchanged. A seller that passes nopricing_versionignoresif_pricing_version, as the spec allows. Two cases fall back to a full payload as a conservative choice: a seller that separates pricing receiving noif_pricing_version, and continuation pages (pagination.cursorset).feed_unchangedrunscheck_feed_preconditionsfirst.wholesale_feed_version. A test applies a webhook to a realFeedMirror, refreshes it, and checks that the seller answersunchanged: true.Tests
tests/test_feed_version.py(60 tests) covers:generated_atignored by path but not by key name, and array traversal in paths.extra, non-I-JSON input, and pricing separation and binding.field, for dicts and models. Each case is cross-checked againstvalidate_request, so the helper and the schema agree.validate_response.FeedMirror(paginated bootstrap, unchanged refresh, re-price refresh, with and without separate pricing) runs against a seller built from these helpers. Every response is schema-validated.Closes #1676
🤖 Generated with Claude Code