You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
RFC: standalone collections read on the sales agent (#8137 item 4) #8183
#8137 item 4, deferred from PR #8141. Buyers can now get full collection objects with include_collections: true, but only for collections that products returned by get_products, list_products, request_proposals or refine_proposals reference. There's no way to browse a sales agent's collection catalog outside a brief, or to fetch one collection by (publisher_domain, collection_id). That's what a creator marketplace or podcast network needs for catalog pages and for refreshing one card.
The only alternative is the publisher's adagents.json, which is static, can hold thousands of entries, and exists for authorization.
Questions to settle
Task shape: a new task, or a mode on an existing read.
Paging and filters: how to page through the catalog, and which filters to offer (kind, genre, talent brand_ref, distribution platform).
Design: standalone collections read on the sales agent
Status: accepted as recommended (2026-10-17); implementation PR to follow. All open questions below were accepted as recommended, including 7e: a follower refresh that leaves the bounds unchanged fires no event, and #8098's as_of is redefined to mean when the count last changed. The stale collection-selector.json description (collections resolve only through adagents.json) is fixed in the same PR. Follows #8137 item 4, deferred from #8141.
Recommendation
Add one experimental read-only task, list_collections, on the sales agent, under a new feature id media_buy.collection_catalog. It returns the same core/response-collection.json objects that include_collections returns, under the same authority rules, unchanged. One task covers both jobs: looking up specific collections by key, and browsing the catalog with filters and pages. Account-level webhooks (collection_card.updated, collection_card.removed, collection_catalog.bulk_change) push card changes to buyer mirrors, modelled on the wholesale feed webhooks, with list_collections as the bootstrap and repair read. The changeset is minor, and the task is additive and experimental.
Revision 2026-10-17: webhooks moved into this slice (section 5); question 7 became the webhook shape questions 7a–7g.
1. Task shape
Make it a new task, not a mode on an existing read.
list_products returns only products. Its collections array is scoped to the products on the page, and its feed_version versions products. A collections-only mode would break all three rules. list_products is also optional (lifecycle_tools), so a seller that offers proposals but no wholesale feed would have to build a feed just to expose its catalog.
get_products is a deprecated 3.x facade. Adding a mode would put new work on a surface that is being retired.
Tool budget: the request schema is small (selectors, about eight filters, pagination). The bundled tools/list input schema is an estimated 5–7 KiB, against about 39 KiB for each of the four discovery tools (157 KiB bound in server/tests/unit/product-discovery-schema-parity.test.ts). The real cost is one more tool name and description in an agent's tool list. That is acceptable for a read whose name says what it does.
Name: list_ matches the other side-effect-free catalog reads (list_products, list_creative_formats, list_creatives), which also accept ID filters. list_collections sits next to list_collection_lists, but that task belongs to the governance agent and lists buyer-owned targeting lists, not seller catalog entries. The task description should state the difference in one line.
2. Request
collections: an array of core/collection-selector.json, the same shape as a product's collections. A buyer can pass a product's selectors through unchanged.
A selector with collection_ids is a key lookup.
A selector with only publisher_domain means every collection of that publisher that this seller represents.
At most 100 collection IDs in total across all selectors.
filters (all optional; values within one filter are OR, and different filters are AND):
kinds, genres_include, genres_exclude and genre_taxonomy, with the same names and semantics as collection/collection-list-filters.json.
statuses, using the collection-status enum.
languages, as BCP 47 tags.
talent: an array of {domain, brand_id}, matched against talent[].brand_ref. Only domain and brand_id are read, the same rule as on talent. This is an inline shape rather than core/brand-ref.json, which would add 5 KiB of fields that are ignored here.
platforms: domains matched against distribution[].publisher_domain.
name_contains.
pagination: core/pagination-request.json (default 50, maximum 100). This is the shared convention used by list_creatives, list_creative_formats, list_accounts, get_signals and list_collection_lists, rather than the flat cursor fields of list_products.
account: optional. if_collections_version: optional (see 4).
The request rejects unknown fields. An unsupported filter must fail with VALIDATION_ERROR, never silently return a broader result.
The seller applies selectors and filters before pagination. Order is seller-defined and stable within one collections_version. There is no sort parameter. In particular there is no sort by follower count, because followers must not be summed or compared across distribution entries.
3. Response
outcome: listed or unchanged. This is the same discriminator as list_products.
collections[] (core/response-collection.json, minItems 0 on listed), plus pagination, collections_version and cache_scope.
The copy wins only on images, sample_content, url, description, season, cadence and distribution[].followers.
A copy is never authorization evidence. Copies of undeclared collections, or from unauthorized agents, are unverified display data and never feed carriage verification.
Definition pins hash adagents.json, never the copy.
Filters run on the seller's copy, so a match on an anchored field (genre, status, talent, kind) is a discovery hint. Before relying on those fields for suitability, buyers check them against adagents.json, exactly as they do for a response copy today.
A selector key that is unknown, or not visible to the caller, is omitted silently, with no per-key error. This is the uniform-response rule behind REFERENCE_NOT_FOUND: "doesn't exist" and "not yours" look the same. Buyers detect omissions by diffing their selectors against the returned entries, as they already do with include_collections.
4. Versioning and caching
Its own token. Add collections_version and if_collections_version. A match returns outcome: "unchanged" without collections.
The scope is (agent, canonicalized collections selectors, canonicalized filters), plus account identity when cache_scope is account.
Arrays have set semantics.
pagination.cursor is outside the scope.
The pagination-restart rule from wholesale feed versioning applies: if a page arrives with a different version, restart from the first page.
Independent of product feeds. The token does not reuse the feat(schema): experimental collection objects in product and proposal responses #8141include_collections scope. That scope exists so that card changes don't force product mirrors to refetch, and this read has no products to version. A collections_version change says nothing about feed_version or wholesale_feed_version, and neither of those says anything about this token.
cache_scope.
A request without account gets public.
A request with account gets public or account, which the seller must declare, with the same downgrade semantics as wholesale feeds.
Only the set of visible collections may vary by account. The content of a copy for a given key may not. There is one card per collection per seller.
Deferred: a delta read (updated_since, which needs removal tombstones). Webhooks (section 5) cover push.
5. Webhooks
Why they're needed. Registry collection.* events (core/registry-event.json) report the publisher's authoritative adagents.json catalog. A seller's card fields (images, sample_content, url, description, season, cadence, distribution[].followers) never reach the registry. A buyer that mirrors cards therefore has no push channel today. That is the same gap product.* webhooks fill for products. Today #8141 tells a webhook-fed product mirror to refresh cards "through a full read with include_collections: true". These events replace that full read.
Subscription. Subscriptions are account-level and durable, through sync_accounts.accounts[].notification_configs[], exactly like product.* and wholesale_feed.bulk_change. They are not set up per request, and push_notification_config on a list_collections call creates no subscription. notification_configs[].event_types already forbids sellers from silently widening a filter, so a buyer receives these events only after listing them.
Events. The three types are added to enums/notification-type.json, to the notification-config.jsonevent_types enum, to wholesale_feed_webhooks.event_types, and as new branches of core/wholesale-feed-event.json.
Event
Fires when
event.payload
collection_card.updated
A card enters the seller's catalog for a scope, or any field of its copy changes. This is an upsert.
collection: the full post-change core/response-collection.json, which replaces the mirror entry. Also optional advisory changed_fields[] and required applies_to.
collection_card.removed
The seller no longer offers the card in that scope
publisher_domain, collection_id, removal_reason (the existing removalReason enum) and applies_to. Never a card.
collection_catalog.bulk_change
One operation touches more cards than the threshold below
The wholesale_feed.bulk_change payload without affected_entity_type: summary, affected_count, recommendation: "wholesale_resync" and applies_to. No cards.
Names.
Not collection.*. Registry events already use collection.created, collection.updated, collection.removed and collection.merged for the publisher's declaration. Buyers often route both feeds through one dispatcher keyed on the type string. If a seller's display copy shared those names, it could be misread as the publisher's declaration, and that is a trust bug.
collection_card names what is pushed: the seller's copy, which wins only on the closed list. The names follow the entity.verb convention (product.updated, creative.status_changed, account.change_recorded). Underscores inside a segment have precedent in wholesale_feed and status_changed.
The bulk event is namespaced by its feed, like wholesale_feed.bulk_change, and matches the feature id. It does not reuse wholesale_feed.bulk_change with a new affected_entity_type: "collection". That enum is closed (product, signal) in a stable schema. Every existing wholesale_feed.bulk_change subscriber would start receiving an experimental event it never opted into, and might treat it as a product resync. A separate type keeps the opt-in at the event_types level.
No .created event. The mirror is keyed by (publisher_domain, collection_id), and "new" and "changed" lead to the same action. An upsert also cannot arrive out of order as an "update before create".
Envelope. Reuse core/wholesale-feed-webhook.json without changing its shape. That gives one receiver, one signing profile and one set of delivery rules.
notification_id equals event.event_id, and notification_type equals event.event_type.
wholesale_feed_version carries the post-change collections_version. This follows precedent: the list_productsfeed_version already travels in that field.
event.entity_type gains collection for card events, and the bulk event uses feed.
entity_id is opaque to the seller. Receivers key the mirror on the payload's (publisher_domain, collection_id), never on entity_id.
Delivery follows the persistent webhook contract unchanged: at least once, with dedupe on idempotency_key, no ordering guarantee, and RFC 9421 signing (webhook_signing.supported MUST be true once wholesale_feed_webhooks.supported is).
Volume.
Per-card collection_card.updated is expected for an editorial change to one card (a new thumbnail, a new sample, a description or season edit, a status change), and for a single card entering the catalog. The same goes for collection_card.removed when one card leaves.
collection_catalog.bulk_change is for operations at scale. A seller SHOULD send one bulk event instead of per-card events when one operation touches more than 5% of the catalog or more than 100 cards, whichever is smaller. This is the wholesale feed threshold. A scheduled sweep that refreshes follower counts is the typical case and SHOULD always go out as one bulk event, never as a stream of per-card events.
A refresh that leaves a count's bounds and precision unchanged is not a card change. It fires nothing and produces no new version, and the earlier as_of stays (question 7e).
Versions and repair. These follow the wholesale feed rules, applied to list_collections.
The webhook version describes post-change state of the unfiltered catalog (no selectors and no filters) for its cache_scope. A webhook-fed mirror therefore bootstraps from an unfiltered list_collections read of every page, and stores collections_version and cache_scope. Tokens from filtered reads cannot be compared with webhook versions.
After applying an event, the mirror stores the event's version.
On a gap (previous_wholesale_feed_version doesn't match the stored version), a failed signature, or a stale version, the mirror repairs with list_collections and if_collections_version set to its last applied version. The webhook's version is never the conditional token for a stale mirror.
On collection_catalog.bulk_change, or any uncertain state, the mirror drops the token and replaces itself from a complete unfiltered read.
Conditional reads are the repair path. A mirror does not need to poll while webhooks are healthy.
Authority. A pushed card is a response copy, and the #8141 rules apply unchanged.
The card wins over adagents.json only on the closed list.
It is never authorization evidence.
A card for an undeclared collection, or from an agent adagents.json does not authorize, is display-only and never feeds carriage verification.
collection_card.removed means only that this seller stopped offering the card. It does not say the collection was retired or that authorization changed; those facts arrive as registry collection.removed and authorization.revoked.
A buyer recomputes its merged view when either feed fires. A card event changes closed-list fields, a registry event changes anchored fields, and neither overrides the other's fields.
Scope and privacy.
applies_to is required on every card event.
Since copy content does not vary by account (question 5), account-scoped events only move a card into or out of an account's visible set. Content changes to a public card are public events.
Sellers MUST apply the list_collections visibility predicate when they emit. A subscriber whose account cannot see a card through list_collections MUST NOT receive its events.
Subscribing requires an account, as it does for product webhooks. Buyers browsing without one poll.
6. Authorization and privacy
The seller returns only collections it represents and can sell to the caller, meaning collections its products offered to that caller can reference. Ended and upcoming collections are allowed, with their status.
Public versus account visibility: see cache_scope above. Invisible keys are omitted, as described in section 3.
Card fields are public display data. The task returns no pricing, availability or account terms.
7. Capability and feature id
Add a new id, media_buy.collection_catalog, covering list_collections, its request and response schemas, and the three webhook event types. A seller that declares it MUST also declare media_buy.collection_cards, because the response carries card fields.
Why a separate id: a seller can serve cards in-band without running a catalog read, which is what Availnet does today. The two surfaces can also graduate on their own schedules.
The task does not go in lifecycle_tools, which is the product and MediaBuy lifecycle list. A buyer sees the feature id and the tool in tools/list or the A2A skills.
Webhooks are optional within the feature, and there is no separate id. Feature ids name clusters, and the push half belongs to the same cluster as the read it repairs through. The fine-grained declaration already exists: a seller lists the types it emits in wholesale_feed_webhooks.event_types, with supported: true. A seller can declare the read without push.
Consistency rule (alongside the existing product.* and signal.* rules): listing any collection_card.* event or collection_catalog.bulk_change requires declaring media_buy.collection_catalog, because list_collections is the repair path. Buyers MUST NOT register those types with a seller that doesn't list them, and sellers reject undeclared types as they do today.
8. Relationship to existing reads
Both reads serve the same copy and authority rules. Invariant: for the same key, account and moment, a seller MUST return the same copy from list_collections and from a product read with include_collections: true.
Buyer need
Use
Cards for the products in this answer (brief, proposal, feed page)
include_collections: true on that read. One round trip, and scoped to what is being offered.
A catalog page: browse creators or shows outside a brief
list_collections with filters
Refreshing one card (stale followers.as_of, or a new thumbnail)
list_collections with a key selector
Keeping a card mirror current (a catalog site, or cards beside a webhook-fed product mirror)
Bootstrap with an unfiltered list_collections, subscribe to the collection_card.* and collection_catalog.bulk_change events, and repair with list_collections. Join cards to products on the selector keys.
A collection the seller omitted from an include_collections response
list_collections by key, then fall back to adagents.json
Following a get_brand_identitycollections link that has seller_agent_url
list_collections by key on that agent (the URL is a routing hint; authorization is still checked in adagents.json)
Products for a chosen collection
A brief through request_proposals, or list_products with targeting_overlay.collection_selection. list_collections never returns products.
Whether an agent may sell a collection
Only adagents.json
9. Compliance testing
With the spec PR:
Schema tests, following the pattern of tests/fast-channel-collections.test.cjs. They cover the new event-union branches, and check that the three event types appear in all four enums (notification-type, notification-configevent_types, wholesale_feed_webhooks.event_types, and wholesale-feed-eventevent_type) with the matching entity_type.
A test-vector set in static/compliance/source/test-vectors/collection-copies/, modelled on the hold-policy vectors (no storyboard yet; frozen inputs and expected violations). The vectors grade:
(a) The buyer's merged view: copy plus adagents.json gives the effective card, including unverified and duplicate cases. This is shared with include_collections, which has no vectors today.
(b) Seller responses: adherence to selectors and filters, uniqueness, silent omission, token scoping (selector or filter changes are a mismatch), unchanged carrying no collections, and cache_scope with no account.
(c) Webhook payloads:
notification_id equals event_id, and notification_type equals event_type.
cache_scope equals applies_to.scope.
updated carries a full card with publisher_domain.
removed carries the key and no collection.
Bulk carries no cards.
A card event without applies_to is invalid.
(d) Mirror application: given a mirror state, its stored version and a sequence of events, the expected mirror, or a verdict of repair_conditional (with the token to send) or repair_full. Cases cover a gap detected from previous_wholesale_feed_version, out-of-order arrival, bulk change, a retried idempotency_key, and the rule that a webhook version is never the conditional token.
These vectors grade buyer SDKs and seller test suites before any agent can emit the events.
A storyboard media_buy_seller/collection_catalog, gated by requires_capability: experimental_features contains media_buy.collection_catalog.
Steps: capabilities; browse two pages; key lookup; get_products with include_collections and an assertion that shared keys return the same copy; a conditional read that returns unchanged; a token presented with different filters that returns listed.
A registration storyboard modelled on universal/wholesale-feed-product-webhooks.yaml, gated on wholesale_feed_webhooks.event_types contains collection_card.updated. sync_accounts must accept the three types, and list_accounts must echo them.
A delivery storyboard. It needs a trigger, so add a seed_collectioncomply_test_controller scenario that upserts or removes a card on a sandbox account. The storyboard then asserts collection_card.updated and collection_card.removed with expect_webhook and requires_contract: webhook_receiver_runner, as protocols/media-buy/scenarios/account_change_feed.yaml does. A seeded batch above the threshold must produce exactly one collection_catalog.bulk_change.
collections-and-installments.mdx line 140 ("A standalone collection read ... is future work"): replace it with a link to the new task.
The "Card display and freshness facts" row in the fact-home table should name list_collections too.
In collections-and-installments.mdx (Feed versions), get_products.mdx and list_products.mdx, the feat(schema): experimental collection objects in product and proposal responses #8141 sentence "a webhook-fed mirror refreshes cards through a full read with include_collections: true" should point to the collection_card.* events as the push path.
specs/wholesale-feed-webhooks.md gains the three types in its event table, the new consistency rule, and list_collections in its repair paths. The wholesale_feed_webhooks capability and envelope descriptions stop saying "products and signals" only.
Separately, and this is a bug on main today: the "Creator and show cards" section (line 1172) still says collection objects reach buyers only through adagents.json, "not inline in get_products", and that ranking needs one fetch per publisher. feat(schema): experimental collection objects in product and proposal responses #8141 made that untrue. Fix it whether or not this RFC lands.
Open questions
Accepted as recommended on 2026-10-17:
New task, not a mode. Accepted: yes.
Name. Accepted: list_collections.
Separate feature id media_buy.collection_catalog. Accepted: separate.
Own collections_version / if_collections_version. Accepted: yes.
Same copy across both reads, with only the visible set varying by account. Accepted: yes.
Silent omission of unknown or invisible keys. Accepted: yes.
The shared pagination object. Accepted.
3.3 beta if merged before the cut, otherwise 3.4. Accepted.
Question 7 (no webhooks) is withdrawn. Webhooks are in this slice. Open:
7a. Event names collection_card.updated, collection_card.removed and collection_catalog.bulk_change, rather than collection.* or reusing wholesale_feed.bulk_change (yes / no)? Recommend yes.
7b. An upsert collection_card.updated with no separate collection_card.created (yes / no)? Recommend yes.
7c. Envelope: reuse core/wholesale-feed-webhook.json with collections_version in wholesale_feed_version, or define a new collection-catalog envelope? Recommend reuse.
7d. Webhook versions describe the unfiltered catalog only, so a webhook-fed mirror bootstraps from an unfiltered read (yes / no)? Recommend yes.
7e. A follower refresh that leaves the bounds and precision unchanged is not a change: no event, no new version, and the earlier as_of is kept (yes / no)? Recommend yes. The cost: as_of then means "last time the count moved", not "last time it was read". Without this rule, a daily sweep forces a daily full resync.
7f. Webhooks sit inside media_buy.collection_catalog, declared through wholesale_feed_webhooks.event_types, with no separate feature id (yes / no)? Recommend yes.
Not in this slice: lookup by distribution identifier (for example youtube_channel_id), a fields projection, sorting, an updated_since delta read, future-dated (effective_at) card events, and an offer_filters collection filter on list_products.
Appendix A: examples
All domains are fictional.
Key lookup to refresh two cards. The first selector is copied from a product's collections:
collection_card.removed: the key only, with no card. (Only the event object is shown; the envelope is as above, with notification_type and notification_id matching.)
collection_catalog.bulk_change: the nightly follower sweep. It carries no cards, and the mirror replaces itself from a complete unfiltered list_collections read without if_collections_version.
Collections-only mode on list_products (for example, empty fields plus include_collections)
No new tool
Breaks the rules that "collections only alongside products" and "scoped to products on the page". Overloads feed_version and outcome. list_products is optional, so proposal-only sellers would need a feed.
Rejected
New buying_mode on get_products
No new tool, and get_products accepts unknown fields
It is a deprecated facade. The brief, wholesale and refine semantics don't apply. wholesale_feed_version would gain a third meaning.
Rejected
Two tasks: get_collections (keys) and list_collections (browse)
Two tools for one object. Selectors already express both.
Rejected
Reuse the #8141include_collections feed-version scope
One versioning story
There are no products to version, and coupling the tokens would make card churn invalidate product mirrors, which is what #8141 avoided
Rejected
No conditional fetch (pagination only)
Smallest change
Catalogs of thousands of entries get re-downloaded on every sync, while a stable podcast-network catalog rarely changes. It is cheap for sellers to hash.
Rejected, but cheap to drop if the WG disagrees
updated_since delta read
Efficient for large, churning catalogs (follower counts change daily)
Needs removal tombstones and clock semantics. Webhooks now cover push, and no adopter is asking for a pull delta.
Deferred
No webhooks (conditional reads only)
Smallest change
Card fields never reach the registry, so a card mirror would have no push channel. This is the gap product.* fills for products.
Rejected (user, 2026-10-17)
Event names collection.updated and collection.removed
Shortest names
They collide with registry collection.* events, which describe the publisher's declaration. A shared dispatcher could treat a seller's display copy as authoritative.
Rejected
wholesale_feed.bulk_change with affected_entity_type: "collection"
One bulk event for every feed, as signals do
Extends a closed enum in a stable schema. Existing bulk subscribers would receive an experimental event they never opted into, and might run a product resync.
Rejected
Invalidation-only card events (key, no card)
Smaller payloads
One read per event. Wholesale feeds chose denormalized replacement payloads for this reason.
Rejected
Per-card events for every follower refresh
Freshest counts
Thousands of events per sweep, for approximate display data that carries its own as_of
Rejected; sweeps go out as one bulk event
A new collection-catalog webhook envelope
The version field could be named collections_version
A second envelope, signing path and dispatcher for the same delivery contract. list_products already carries feed_version in wholesale_feed_version.
Rejected (question 7c)
A separate feature id for card webhooks
Independent graduation
Feature ids name clusters, and wholesale_feed_webhooks.event_types already declares the push surface at the level of individual types
Rejected (question 7f)
Sellers push card fields into the registry
One feed for buyers
The registry is a crawl of publisher declarations. Mixing seller copies into it blurs authority, and it centralizes freshness.
Rejected
A static seller-hosted feed, or an adagents.json pointer to a collections file
No task at all
Static, with no account visibility. Puts display churn next to authorization data, which is the problem in the first place.
Crawled from adagents.json, so it lacks the seller's fresher copy. Centralized, and not the seller's view of what it sells.
Complementary, not a substitute
Lookup by distribution identifier
Lets a buyer ask "do you sell this channel?" across sellers
platform_channel_id needs the (publisher_domain, value) tuple, a shape question to settle on its own
Deferred
Prior art: #8141 (response collections, authority, feed-version scope), #8094 (cards), #8098 (followers and pin exclusion), the hold-policy test vectors (vectors before storyboards), list_account_changes (experimental standalone read with its own feature id), specs/wholesale-feed-webhooks.md (account-level registration, denormalized payloads, the bulk threshold, repair through the read), and the HTTP ETag / If-None-Match pattern behind if_wholesale_feed_version.
Implemented as designed in #8279, now merged to main as an experimental slice (media_buy.collection_catalog):
list_collections with key lookup and filtered browse;
the collection_card.updated, collection_card.removed and collection_catalog.bulk_change webhooks, registered through sync_accounts;
the redefined as_of on follower counts;
the collection-selector.json description fix;
58 compliance test vectors with a reference grader.
Two clarifications made during implementation:
filters.languages uses RFC 4647 basic filtering over core/locale-tag.json values.
A seller copy's follower count attaches to the publisher-declared distribution entry with the same publisher_domain (or the same identifiers when several share it). Undeclared entries are ignored.
Storyboards follow the training agent work in #8181.
Activity
bokelley commentedon Oct 17, 2026
Design: standalone collections read on the sales agent
Status: accepted as recommended (2026-10-17); implementation PR to follow. All open questions below were accepted as recommended, including 7e: a follower refresh that leaves the bounds unchanged fires no event, and #8098's
as_ofis redefined to mean when the count last changed. The stalecollection-selector.jsondescription (collections resolve only throughadagents.json) is fixed in the same PR. Follows #8137 item 4, deferred from #8141.Recommendation
Add one experimental read-only task,
list_collections, on the sales agent, under a new feature idmedia_buy.collection_catalog. It returns the samecore/response-collection.jsonobjects thatinclude_collectionsreturns, under the same authority rules, unchanged. One task covers both jobs: looking up specific collections by key, and browsing the catalog with filters and pages. Account-level webhooks (collection_card.updated,collection_card.removed,collection_catalog.bulk_change) push card changes to buyer mirrors, modelled on the wholesale feed webhooks, withlist_collectionsas the bootstrap and repair read. The changeset isminor, and the task is additive and experimental.Revision 2026-10-17: webhooks moved into this slice (section 5); question 7 became the webhook shape questions 7a–7g.
1. Task shape
Make it a new task, not a mode on an existing read.
list_productsreturns only products. Itscollectionsarray is scoped to the products on the page, and itsfeed_versionversions products. A collections-only mode would break all three rules.list_productsis also optional (lifecycle_tools), so a seller that offers proposals but no wholesale feed would have to build a feed just to expose its catalog.get_productsis a deprecated 3.x facade. Adding a mode would put new work on a surface that is being retired.tools/listinput schema is an estimated 5–7 KiB, against about 39 KiB for each of the four discovery tools (157 KiB bound inserver/tests/unit/product-discovery-schema-parity.test.ts). The real cost is one more tool name and description in an agent's tool list. That is acceptable for a read whose name says what it does.list_matches the other side-effect-free catalog reads (list_products,list_creative_formats,list_creatives), which also accept ID filters.list_collectionssits next tolist_collection_lists, but that task belongs to the governance agent and lists buyer-owned targeting lists, not seller catalog entries. The task description should state the difference in one line.2. Request
collections: an array ofcore/collection-selector.json, the same shape as a product'scollections. A buyer can pass a product's selectors through unchanged.collection_idsis a key lookup.publisher_domainmeans every collection of that publisher that this seller represents.filters(all optional; values within one filter are OR, and different filters are AND):kinds,genres_include,genres_excludeandgenre_taxonomy, with the same names and semantics ascollection/collection-list-filters.json.statuses, using thecollection-statusenum.languages, as BCP 47 tags.talent: an array of{domain, brand_id}, matched againsttalent[].brand_ref. Onlydomainandbrand_idare read, the same rule as on talent. This is an inline shape rather thancore/brand-ref.json, which would add 5 KiB of fields that are ignored here.platforms: domains matched againstdistribution[].publisher_domain.name_contains.pagination:core/pagination-request.json(default 50, maximum 100). This is the shared convention used bylist_creatives,list_creative_formats,list_accounts,get_signalsandlist_collection_lists, rather than the flat cursor fields oflist_products.account: optional.if_collections_version: optional (see 4).VALIDATION_ERROR, never silently return a broader result.The seller applies selectors and filters before pagination. Order is seller-defined and stable within one
collections_version. There is no sort parameter. In particular there is no sort by follower count, becausefollowersmust not be summed or compared across distribution entries.3. Response
outcome:listedorunchanged. This is the same discriminator aslist_products.collections[](core/response-collection.json,minItems0 onlisted), pluspagination,collections_versionandcache_scope.adagents.jsonis authoritative.images,sample_content,url,description,season,cadenceanddistribution[].followers.adagents.json, never the copy.genre,status,talent,kind) is a discovery hint. Before relying on those fields for suitability, buyers check them againstadagents.json, exactly as they do for a response copy today.(publisher_domain, collection_id). The duplicate rule from feat(schema): experimental collection objects in product and proposal responses #8141 applies: the key is unresolvable, and the buyer falls back toadagents.json.REFERENCE_NOT_FOUND: "doesn't exist" and "not yours" look the same. Buyers detect omissions by diffing their selectors against the returned entries, as they already do withinclude_collections.4. Versioning and caching
collections_versionandif_collections_version. A match returnsoutcome: "unchanged"withoutcollections.collectionsselectors, canonicalizedfilters), plus account identity whencache_scopeisaccount.pagination.cursoris outside the scope.include_collectionsscope. That scope exists so that card changes don't force product mirrors to refetch, and this read has no products to version. Acollections_versionchange says nothing aboutfeed_versionorwholesale_feed_version, and neither of those says anything about this token.cache_scope.accountgetspublic.accountgetspublicoraccount, which the seller must declare, with the same downgrade semantics as wholesale feeds.updated_since, which needs removal tombstones). Webhooks (section 5) cover push.5. Webhooks
Why they're needed. Registry
collection.*events (core/registry-event.json) report the publisher's authoritativeadagents.jsoncatalog. A seller's card fields (images,sample_content,url,description,season,cadence,distribution[].followers) never reach the registry. A buyer that mirrors cards therefore has no push channel today. That is the same gapproduct.*webhooks fill for products. Today #8141 tells a webhook-fed product mirror to refresh cards "through a full read withinclude_collections: true". These events replace that full read.Subscription. Subscriptions are account-level and durable, through
sync_accounts.accounts[].notification_configs[], exactly likeproduct.*andwholesale_feed.bulk_change. They are not set up per request, andpush_notification_configon alist_collectionscall creates no subscription.notification_configs[].event_typesalready forbids sellers from silently widening a filter, so a buyer receives these events only after listing them.Events. The three types are added to
enums/notification-type.json, to thenotification-config.jsonevent_typesenum, towholesale_feed_webhooks.event_types, and as new branches ofcore/wholesale-feed-event.json.event.payloadcollection_card.updatedcollection: the full post-changecore/response-collection.json, which replaces the mirror entry. Also optional advisorychanged_fields[]and requiredapplies_to.collection_card.removedpublisher_domain,collection_id,removal_reason(the existingremovalReasonenum) andapplies_to. Never a card.collection_catalog.bulk_changewholesale_feed.bulk_changepayload withoutaffected_entity_type:summary,affected_count,recommendation: "wholesale_resync"andapplies_to. No cards.Names.
collection.*. Registry events already usecollection.created,collection.updated,collection.removedandcollection.mergedfor the publisher's declaration. Buyers often route both feeds through one dispatcher keyed on the type string. If a seller's display copy shared those names, it could be misread as the publisher's declaration, and that is a trust bug.collection_cardnames what is pushed: the seller's copy, which wins only on the closed list. The names follow theentity.verbconvention (product.updated,creative.status_changed,account.change_recorded). Underscores inside a segment have precedent inwholesale_feedandstatus_changed.wholesale_feed.bulk_change, and matches the feature id. It does not reusewholesale_feed.bulk_changewith a newaffected_entity_type: "collection". That enum is closed (product,signal) in a stable schema. Every existingwholesale_feed.bulk_changesubscriber would start receiving an experimental event it never opted into, and might treat it as a product resync. A separate type keeps the opt-in at theevent_typeslevel..createdevent. The mirror is keyed by(publisher_domain, collection_id), and "new" and "changed" lead to the same action. An upsert also cannot arrive out of order as an "update before create".Envelope. Reuse
core/wholesale-feed-webhook.jsonwithout changing its shape. That gives one receiver, one signing profile and one set of delivery rules.notification_idequalsevent.event_id, andnotification_typeequalsevent.event_type.account_idis required.cache_scopeequalsevent.payload.applies_to.scope.product_payload_viewis omitted.wholesale_feed_versioncarries the post-changecollections_version. This follows precedent: thelist_productsfeed_versionalready travels in that field.event.entity_typegainscollectionfor card events, and the bulk event usesfeed.entity_idis opaque to the seller. Receivers key the mirror on the payload's(publisher_domain, collection_id), never onentity_id.idempotency_key, no ordering guarantee, and RFC 9421 signing (webhook_signing.supportedMUST betrueoncewholesale_feed_webhooks.supportedis).Volume.
collection_card.updatedis expected for an editorial change to one card (a new thumbnail, a new sample, a description orseasonedit, a status change), and for a single card entering the catalog. The same goes forcollection_card.removedwhen one card leaves.collection_catalog.bulk_changeis for operations at scale. A seller SHOULD send one bulk event instead of per-card events when one operation touches more than 5% of the catalog or more than 100 cards, whichever is smaller. This is the wholesale feed threshold. A scheduled sweep that refreshes follower counts is the typical case and SHOULD always go out as one bulk event, never as a stream of per-card events.precisionunchanged is not a card change. It fires nothing and produces no new version, and the earlieras_ofstays (question 7e).Versions and repair. These follow the wholesale feed rules, applied to
list_collections.cache_scope. A webhook-fed mirror therefore bootstraps from an unfilteredlist_collectionsread of every page, and storescollections_versionandcache_scope. Tokens from filtered reads cannot be compared with webhook versions.previous_wholesale_feed_versiondoesn't match the stored version), a failed signature, or a stale version, the mirror repairs withlist_collectionsandif_collections_versionset to its last applied version. The webhook's version is never the conditional token for a stale mirror.collection_catalog.bulk_change, or any uncertain state, the mirror drops the token and replaces itself from a complete unfiltered read.Authority. A pushed card is a response copy, and the #8141 rules apply unchanged.
adagents.jsononly on the closed list.adagents.jsondoes not authorize, is display-only and never feeds carriage verification.collection_card.removedmeans only that this seller stopped offering the card. It does not say the collection was retired or that authorization changed; those facts arrive as registrycollection.removedandauthorization.revoked.Scope and privacy.
applies_tois required on every card event.publicevents.list_collectionsvisibility predicate when they emit. A subscriber whose account cannot see a card throughlist_collectionsMUST NOT receive its events.6. Authorization and privacy
status.adagents.jsonauthorizes it (directly, or through a collection-scoped selector). Any other entry is unverified display data under the feat(schema): experimental collection objects in product and proposal responses #8141 rules. The seller cannot make it more than that.cache_scopeabove. Invisible keys are omitted, as described in section 3.7. Capability and feature id
media_buy.collection_catalog, coveringlist_collections, its request and response schemas, and the three webhook event types. A seller that declares it MUST also declaremedia_buy.collection_cards, because the response carries card fields.lifecycle_tools, which is the product and MediaBuy lifecycle list. A buyer sees the feature id and the tool intools/listor the A2A skills.wholesale_feed_webhooks.event_types, withsupported: true. A seller can declare the read without push.product.*andsignal.*rules): listing anycollection_card.*event orcollection_catalog.bulk_changerequires declaringmedia_buy.collection_catalog, becauselist_collectionsis the repair path. Buyers MUST NOT register those types with a seller that doesn't list them, and sellers reject undeclared types as they do today.8. Relationship to existing reads
Both reads serve the same copy and authority rules. Invariant: for the same key, account and moment, a seller MUST return the same copy from
list_collectionsand from a product read withinclude_collections: true.include_collections: trueon that read. One round trip, and scoped to what is being offered.list_collectionswith filtersfollowers.as_of, or a new thumbnail)list_collectionswith a key selectorlist_collections, subscribe to thecollection_card.*andcollection_catalog.bulk_changeevents, and repair withlist_collections. Join cards to products on the selector keys.include_collectionsresponselist_collectionsby key, then fall back toadagents.jsonget_brand_identitycollectionslink that hasseller_agent_urllist_collectionsby key on that agent (the URL is a routing hint; authorization is still checked inadagents.json)request_proposals, orlist_productswithtargeting_overlay.collection_selection.list_collectionsnever returns products.adagents.json9. Compliance testing
tests/fast-channel-collections.test.cjs. They cover the new event-union branches, and check that the three event types appear in all four enums (notification-type,notification-configevent_types,wholesale_feed_webhooks.event_types, andwholesale-feed-eventevent_type) with the matchingentity_type.static/compliance/source/test-vectors/collection-copies/, modelled on the hold-policy vectors (no storyboard yet; frozen inputs and expected violations). The vectors grade:adagents.jsongives the effective card, including unverified and duplicate cases. This is shared withinclude_collections, which has no vectors today.unchangedcarrying nocollections, andcache_scopewith no account.notification_idequalsevent_id, andnotification_typeequalsevent_type.cache_scopeequalsapplies_to.scope.updatedcarries a full card withpublisher_domain.removedcarries the key and nocollection.applies_tois invalid.repair_conditional(with the token to send) orrepair_full. Cases cover a gap detected fromprevious_wholesale_feed_version, out-of-order arrival, bulk change, a retriedidempotency_key, and the rule that a webhook version is never the conditional token.media_buy_seller/collection_catalog, gated byrequires_capability: experimental_features contains media_buy.collection_catalog.get_productswithinclude_collectionsand an assertion that shared keys return the same copy; a conditional read that returnsunchanged; a token presented with different filters that returnslisted.universal/wholesale-feed-product-webhooks.yaml, gated onwholesale_feed_webhooks.event_types contains collection_card.updated.sync_accountsmust accept the three types, andlist_accountsmust echo them.seed_collectioncomply_test_controllerscenario that upserts or removes a card on a sandbox account. The storyboard then assertscollection_card.updatedandcollection_card.removedwithexpect_webhookandrequires_contract: webhook_receiver_runner, asprotocols/media-buy/scenarios/account_change_feed.yamldoes. A seeded batch above the threshold must produce exactly onecollection_catalog.bulk_change.Doc fixes this would carry
collections-and-installments.mdxline 140 ("A standalone collection read ... is future work"): replace it with a link to the new task.list_collectionstoo.collections-and-installments.mdx(Feed versions),get_products.mdxandlist_products.mdx, the feat(schema): experimental collection objects in product and proposal responses #8141 sentence "a webhook-fed mirror refreshes cards through a full read withinclude_collections: true" should point to thecollection_card.*events as the push path.specs/wholesale-feed-webhooks.mdgains the three types in its event table, the new consistency rule, andlist_collectionsin its repair paths. Thewholesale_feed_webhookscapability and envelope descriptions stop saying "products and signals" only.maintoday: the "Creator and show cards" section (line 1172) still says collection objects reach buyers only throughadagents.json, "not inline inget_products", and that ranking needs one fetch per publisher. feat(schema): experimental collection objects in product and proposal responses #8141 made that untrue. Fix it whether or not this RFC lands.Open questions
Accepted as recommended on 2026-10-17:
list_collections.media_buy.collection_catalog. Accepted: separate.collections_version/if_collections_version. Accepted: yes.paginationobject. Accepted.Question 7 (no webhooks) is withdrawn. Webhooks are in this slice. Open:
collection_card.updated,collection_card.removedandcollection_catalog.bulk_change, rather thancollection.*or reusingwholesale_feed.bulk_change(yes / no)? Recommend yes.collection_card.updatedwith no separatecollection_card.created(yes / no)? Recommend yes.core/wholesale-feed-webhook.jsonwithcollections_versioninwholesale_feed_version, or define a new collection-catalog envelope? Recommend reuse.precisionunchanged is not a change: no event, no new version, and the earlieras_ofis kept (yes / no)? Recommend yes. The cost:as_ofthen means "last time the count moved", not "last time it was read". Without this rule, a daily sweep forces a daily full resync.media_buy.collection_catalog, declared throughwholesale_feed_webhooks.event_types, with no separate feature id (yes / no)? Recommend yes.seed_collectioncomply_test_controllerscenario, landing with Training agent: serve the experimental 3.3 hold-policy, extend_hold and response-collection surfaces #8181, to drive delivery storyboards (yes / no)? Recommend yes.Not in this slice: lookup by distribution identifier (for example
youtube_channel_id), afieldsprojection, sorting, anupdated_sincedelta read, future-dated (effective_at) card events, and anoffer_filterscollection filter onlist_products.Appendix A: examples
All domains are fictional.
Key lookup to refresh two cards. The first selector is copied from a product's
collections:{ "collections": [ { "publisher_domain": "novacreators.example", "collection_ids": ["kitchen_lab"] }, { "publisher_domain": "pinnaclepods.example", "collection_ids": ["founders_hour"] } ] }Browse a catalog page: active English video series featuring one creator, on one platform:
{ "account": { "account_id": "acct_nova_agency" }, "filters": { "kinds": ["series"], "statuses": ["active"], "languages": ["en"], "talent": [{ "domain": "jordanvega.example" }], "platforms": ["videoplatform.example"] }, "pagination": { "max_results": 50 } }listedresponse (one entry shown):{ "outcome": "listed", "collections": [ { "publisher_domain": "novacreators.example", "collection_id": "kitchen_lab", "name": "Kitchen Lab with Jordan Vega", "kind": "series", "status": "active", "language": "en", "cadence": "weekly", "description": "Weekly food science experiments for home cooks", "url": "https://novacreators.example/creators/kitchen-lab", "images": [ { "asset_type": "image", "url": "https://cdn.novacreators.example/kitchen-lab/avatar.jpg", "width": 800, "height": 800, "alt_text": "Kitchen Lab channel avatar" } ], "sample_content": [ { "asset_type": "published_post", "post_url": "https://videoplatform.example/watch/kl-0142", "platform": "videoplatform.example", "published_at": "2026-10-02T15:00:00Z" } ], "talent": [ { "role": "creator", "name": "Jordan Vega", "brand_ref": { "domain": "jordanvega.example" } } ], "distribution": [ { "publisher_domain": "videoplatform.example", "identifiers": [{ "type": "youtube_channel_id", "value": "UCexample123456" }], "followers": { "lower_bound": 1230000, "upper_bound": 1239999, "precision": "approximate", "as_of": "2026-10-15T00:00:00Z" } } ] } ], "pagination": { "has_more": true, "cursor": "eyJvIjo1MH0=" }, "collections_version": "cc_2026_10_16_r88", "cache_scope": "account" }Conditional re-read with the same account, selectors and filters:
{ "account": { "account_id": "acct_nova_agency" }, "filters": { "kinds": ["series"], "statuses": ["active"], "languages": ["en"], "talent": [{ "domain": "jordanvega.example" }], "platforms": ["videoplatform.example"] }, "if_collections_version": "cc_2026_10_16_r88" }{ "outcome": "unchanged", "collections_version": "cc_2026_10_16_r88", "cache_scope": "account" }A key lookup where
founders_houris unknown or not visible to the caller returnslistedwith onlykitchen_lab. There is no error for the missing key.Webhooks
Registration through
sync_accounts, alongside an existing product subscriber:{ "accounts": [ { "account": { "account_id": "acct_nova_agency" }, "notification_configs": [ { "subscriber_id": "card-mirror", "url": "https://buyer.example/hooks/collection-cards", "event_types": [ "collection_card.updated", "collection_card.removed", "collection_catalog.bulk_change" ], "active": true } ] } ] }collection_card.updated: a new sample on one card. The payload is the full replacement card.{ "idempotency_key": "whk_01JAC2N4S8J4TK8M6D3K9Q2P1A7", "notification_id": "0192a6f0-0000-7000-8000-000000000101", "notification_type": "collection_card.updated", "fired_at": "2026-10-17T14:00:02Z", "subscriber_id": "card-mirror", "account_id": "acct_nova_agency", "wholesale_feed_version": "cc_2026_10_17_r89", "previous_wholesale_feed_version": "cc_2026_10_16_r88", "cache_scope": "public", "event": { "event_id": "0192a6f0-0000-7000-8000-000000000101", "event_type": "collection_card.updated", "entity_type": "collection", "entity_id": "card_7f3a91", "created_at": "2026-10-17T14:00:00Z", "payload": { "collection": { "publisher_domain": "novacreators.example", "collection_id": "kitchen_lab", "name": "Kitchen Lab with Jordan Vega", "kind": "series", "status": "active", "url": "https://novacreators.example/creators/kitchen-lab", "images": [ { "asset_type": "image", "url": "https://cdn.novacreators.example/kitchen-lab/avatar.jpg", "width": 800, "height": 800 } ], "sample_content": [ { "asset_type": "published_post", "post_url": "https://videoplatform.example/watch/kl-0143", "platform": "videoplatform.example", "published_at": "2026-10-16T15:00:00Z" }, { "asset_type": "published_post", "post_url": "https://videoplatform.example/watch/kl-0142", "platform": "videoplatform.example", "published_at": "2026-10-02T15:00:00Z" } ], "distribution": [ { "publisher_domain": "videoplatform.example", "identifiers": [{ "type": "youtube_channel_id", "value": "UCexample123456" }], "followers": { "lower_bound": 1230000, "upper_bound": 1239999, "precision": "approximate", "as_of": "2026-10-15T00:00:00Z" } } ] }, "changed_fields": ["sample_content"], "applies_to": { "scope": "public" } } } }collection_card.removed: the key only, with no card. (Only theeventobject is shown; the envelope is as above, withnotification_typeandnotification_idmatching.){ "event_id": "0192a6f0-0000-7000-8000-000000000102", "event_type": "collection_card.removed", "entity_type": "collection", "entity_id": "card_2b8c44", "created_at": "2026-10-17T15:10:00Z", "payload": { "publisher_domain": "pinnaclepods.example", "collection_id": "founders_hour", "removal_reason": "withdrawn", "applies_to": { "scope": "public" } } }collection_catalog.bulk_change: the nightly follower sweep. It carries no cards, and the mirror replaces itself from a complete unfilteredlist_collectionsread withoutif_collections_version.{ "event_id": "0192a6f0-0000-7000-8000-000000000103", "event_type": "collection_catalog.bulk_change", "entity_type": "feed", "entity_id": "op_followers_2026_10_18", "created_at": "2026-10-18T02:00:00Z", "payload": { "summary": "Nightly follower count refresh", "affected_count": 2140, "recommendation": "wholesale_resync", "applies_to": { "scope": "public" } } }Appendix B: alternatives considered
list_products(for example, emptyfieldsplusinclude_collections)feed_versionandoutcome.list_productsis optional, so proposal-only sellers would need a feed.buying_modeonget_productsget_productsaccepts unknown fieldswholesale_feed_versionwould gain a third meaning.get_collections(keys) andlist_collections(browse)get_collection_list/list_collection_listsinclude_collectionsfeed-version scopeupdated_sincedelta readproduct.*fills for products.collection.updatedandcollection.removedcollection.*events, which describe the publisher's declaration. A shared dispatcher could treat a seller's display copy as authoritative.wholesale_feed.bulk_changewithaffected_entity_type: "collection"as_ofcollections_versionlist_productsalready carriesfeed_versioninwholesale_feed_version.wholesale_feed_webhooks.event_typesalready declares the push surface at the level of individual typesadagents.jsonpointer to a collections file/api/registry/catalog/collections/*)adagents.json, so it lacks the seller's fresher copy. Centralized, and not the seller's view of what it sells.platform_channel_idneeds the(publisher_domain, value)tuple, a shape question to settle on its ownPrior art: #8141 (response collections, authority, feed-version scope), #8094 (cards), #8098 (followers and pin exclusion), the hold-policy test vectors (vectors before storyboards),
list_account_changes(experimental standalone read with its own feature id),specs/wholesale-feed-webhooks.md(account-level registration, denormalized payloads, the bulk threshold, repair through the read), and the HTTPETag/If-None-Matchpattern behindif_wholesale_feed_version.feat(media-buy): experimental list_collections and collection card we…
bokelley commentedon Oct 17, 2026
Implemented as designed in #8279, now merged to
mainas an experimental slice (media_buy.collection_catalog):list_collectionswith key lookup and filtered browse;collection_card.updated,collection_card.removedandcollection_catalog.bulk_changewebhooks, registered throughsync_accounts;as_ofon follower counts;collection-selector.jsondescription fix;Two clarifications made during implementation:
filters.languagesuses RFC 4647 basic filtering overcore/locale-tag.jsonvalues.publisher_domain(or the same identifiers when several share it). Undeclared entries are ignored.Storyboards follow the training agent work in #8181.
bokelley commentedon Nov 2, 2026
Implemented as designed in #8279 (merged 2026-10-17, experimental
media_buy.collection_catalog). Storyboards follow the training-agent work in #8181.