Skip to content

RFC: standalone collections read on the sales agent (#8137 item 4) #8183

Closed

Description

@bokelley

Problem

#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).
  • Versioning: whether it gets its own feed version, or reuses the include_collections scope rules from feat(schema): experimental collection objects in product and proposal responses #8141.
  • Authority: the same rules as response copies. adagents.json stays authoritative, and the copy wins only on the closed list of card fields.

Related: #8137, #8141, #8092.

Activity

  1. added
    rfcProtocol change — auto-adds to roadmap board
    media-buyIssue concerns the media-buy protocol domain
    on Oct 13, 2026
  2. bokelley commented on Oct 17, 2026

    @bokelley
    ContributorAuthor

    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.
    • Authority follows feat(schema): experimental collection objects in product and proposal responses #8141, unchanged:
      • adagents.json is authoritative.
      • 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.
    • At most one entry per (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 to adagents.json.
    • 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 #8141 include_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.json event_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.
    • account_id is required.
    • cache_scope equals event.payload.applies_to.scope.
    • product_payload_view is omitted.
    • wholesale_feed_version carries the post-change collections_version. This follows precedent: the list_products feed_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.
    • It SHOULD return only collections whose publisher's adagents.json authorizes 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.
    • 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_identity collections 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-config event_types, wholesale_feed_webhooks.event_types, and wholesale-feed-event event_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.
    • After Training agent: serve the experimental 3.3 hold-policy, extend_hold and response-collection surfaces #8181 (training agent, which waits for the 3.3 beta):
      • 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_collection comply_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.
      • Track these with Compliance: hold_policy and extend_hold storyboards, plus a comply_test_controller hold-failure scenario #8182, or in a sibling issue.

    Doc fixes this would carry

    • 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:

    1. New task, not a mode. Accepted: yes.
    2. Name. Accepted: list_collections.
    3. Separate feature id media_buy.collection_catalog. Accepted: separate.
    4. Own collections_version / if_collections_version. Accepted: yes.
    5. Same copy across both reads, with only the visible set varying by account. Accepted: yes.
    6. Silent omission of unknown or invisible keys. Accepted: yes.
    7. The shared pagination object. Accepted.
    8. 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.
    • 7g. Add a seed_collection comply_test_controller scenario, 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), 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:

    {
      "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 }
    }

    listed response (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_hour is unknown or not visible to the caller returns listed with only kitchen_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 the event object is shown; the envelope is as above, with notification_type and notification_id matching.)

    {
      "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 unfiltered list_collections read without if_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

    Alternative For Against Verdict
    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) Mirrors get_collection_list / list_collection_lists Two tools for one object. Selectors already express both. Rejected
    Reuse the #8141 include_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. Rejected
    AgenticAdvertising.org registry catalog (/api/registry/catalog/collections/*) Already exists, and searches across publishers 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.

  3. bokelley commented on Oct 17, 2026

    @bokelley
    ContributorAuthor

    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.

  4. bokelley commented on Nov 2, 2026

    @bokelley
    ContributorAuthor

    Implemented as designed in #8279 (merged 2026-10-17, experimental media_buy.collection_catalog). Storyboards follow the training-agent work in #8181.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    media-buyIssue concerns the media-buy protocol domainrfcProtocol change — auto-adds to roadmap boardspec / protocol

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions