Skip to content

feat(server): seller helper for wholesale feed versions - #1706

Merged
bokelley merged 1 commit into
nextfrom
claude/issue-1676-wholesale-feed-version
Oct 29, 2026
Merged

bokelley merged 1 commit into
nextfrom
claude/issue-1676-wholesale-feed-version

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Gap

The SDK has the buyer half of conditional wholesale reads: adcp.feed_mirror sends if_wholesale_feed_version / if_pricing_version and honours unchanged: true. It also has the WholesaleFeedWebhook sender. It has no seller half. A seller serving wholesale get_products / get_signals has to hand-roll three things: a content token, the request rules for the if_* tokens (assert_buying_mode_consistent checks brief and refine only), and the comparator plus the unchanged: true answer.

API

New module adcp.server.feed_version, exported from adcp.server:

Name Purpose
wholesale_feed_version(items, *, cache_scope, ignore=FRESHNESS_FIELDS, extra=None) -> str Content token for the feed
pricing_version(items, *, cache_scope) -> str Token for the pricing layer, for sellers that version pricing separately
check_feed_preconditions(request) Raises AdcpError("INVALID_REQUEST", field=...) when the if_* rules are broken
feed_unchanged(request, *, feed_version, pricing_version=None) -> bool Whether the buyer's tokens are current
FRESHNESS_FIELDS, PRICING_FIELDS Path sets used as defaults above

The unchanged: true answer uses the existing unchanged= option on products_response / signals_response, which is tightened:

  • unchanged=False is no longer emitted; the spec forbids unchanged: false.
  • unchanged=True together with items, or without wholesale_feed_version, raises ValueError. That payload was already schema-invalid.
  • cache_scope is still not required by the builder, because the MCP dispatcher fills in public for requests without an account.

Docs: docs/wholesale-feed-versions.md.

Design decisions

  • Canonicalization. Each item is serialized the way products_response / signals_response puts it on the wire (models via model_dump(mode="json"), None stripped from dicts, the same scrubber). It is then RFC 8785 canonicalized with the rfc8785 package the SDK already uses, and hashed with SHA-256. The token is the SHA-256 of {cache_scope, sorted item digests, extra?}, formatted as sha256:<64 hex>. The schema has no pattern or length constraint on the token.
  • Ordering. Only the order of top-level items is ignored, since a wholesale feed is a set keyed by product_id / signal_agent_segment_id, which is also how FeedMirror indexes it. Arrays inside an item keep their order: some are ordered by contract (forecast.points ascend 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.
  • Ignored fields. ignore takes 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 default FRESHNESS_FIELDS is forecast.generated_at and coverage_forecast.generated_at, the only computation timestamps in the product and signal item schemas. valid_until is kept because buyers read it for expiry.
  • Scope. cache_scope is 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: collections when include_collections is set (the spec requires distinct tokens for the two settings), and signal_pricing_group_rules.
  • Pricing. PRICING_FIELDS is pricing_options and signal_targeting_options.pricing_options, the fields the schema text of GetProductsResponse.pricing_version names. Each price is bound to its item's ID, so swapping prices between items counts as a change. A seller that separates pricing calls wholesale_feed_version(..., ignore=FRESHNESS_FIELDS | PRICING_FIELDS).
  • Comparator. feed_unchanged follows 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 means unchanged. A seller that passes no pricing_version ignores if_pricing_version, as the spec allows. Two cases fall back to a full payload as a conservative choice: a seller that separates pricing receiving no if_pricing_version, and continuation pages (pagination.cursor set). feed_unchanged runs check_feed_preconditions first.
  • Webhook. The same token is passed as the webhook's wholesale_feed_version. A test applies a webhook to a real FeedMirror, refreshes it, and checks that the seller answers unchanged: true.

Tests

tests/test_feed_version.py (60 tests) covers:

  • Determinism, including that a dict item and the equivalent model item give the same token.
  • Top-level order independence and key-order independence, and that order inside an item counts as a change.
  • generated_at ignored by path but not by key name, and array traversal in paths.
  • Sensitivity to every kind of change, plus scope keying, extra, non-I-JSON input, and pricing separation and binding.
  • Precondition errors with the right field, for dicts and models. Each case is cross-checked against validate_request, so the helper and the schema agree.
  • The comparator matrix.
  • The unchanged answer validated against the bundled schemas through validate_response.
  • A round trip in which 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

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>

@aao-secretariat aao-secretariat 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.

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.

@bokelley
bokelley merged commit b014066 into next Oct 29, 2026
41 checks passed
@bokelley
bokelley deleted the claude/issue-1676-wholesale-feed-version branch October 29, 2026 22:13
bokelley added a commit that referenced this pull request Oct 30, 2026
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>
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.

1 participant