Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/wholesale-feed-cache-scope-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"adcontextprotocol": minor
---

signals + media-buy: enforce cache-scope isolation for wholesale-feed conditional fetch

The schemas already state the wholesale-feed token is keyed by `(cache_scope, wholesale_feed_version)`, but nothing exercised that a token minted under one `cache_scope` cannot short-circuit (`unchanged: true`) a request the agent resolves to another. The reference training agent advertises `wholesale_feed_versioning.cache_scope_account: true` yet keys conditional fetch on a scope-independent token, so it would silently answer `unchanged` across scopes — exactly the gap reported in #5739.

- **Schemas** — `signals/get-signals-response.json` and `media-buy/get-products-response.json`: add a normative cross-scope MUST-NOT to the `unchanged` description, scoped to the conditional-fetch comparator (it MUST key on `(cache_scope, wholesale_feed_version)`, not the token alone).
- **Storyboards** — new universal `wholesale-feed-signals-scope-isolation` and `wholesale-feed-products-scope-isolation`, gated on `wholesale_feed_versioning.cache_scope_account: true`; agents without per-account overlays grade `not_applicable`.
- **Reference agent** — scope-key the wholesale feed/pricing tokens so the comparator rejects cross-scope tokens (and the existing same-scope `unchanged` path still matches).

Closes #5739.
2 changes: 2 additions & 0 deletions docs/building/verification/compliance-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ Every agent runs every storyboard in `/compliance/{version}/universal/` regardle
| `notification-config-rejections` | Semantic `notification_configs[]` request rejection path for duplicate `subscriber_id` values. |
| `wholesale-feed-products` | Product wholesale feed versioning — bootstrap responses carry `wholesale_feed_version`/`cache_scope`, and matching `if_wholesale_feed_version` probes return `unchanged` without product rows. |
| `wholesale-feed-signals` | Signals wholesale feed versioning — bootstrap responses carry `wholesale_feed_version`/`cache_scope`, and matching `if_wholesale_feed_version` probes return `unchanged` without signal rows. |
| `wholesale-feed-products-scope-isolation` | Product wholesale feed cache-scope isolation — a `wholesale_feed_version` minted under one `cache_scope` must not short-circuit (`unchanged: true`) a request the seller resolves to a different `cache_scope`. |
| `wholesale-feed-signals-scope-isolation` | Signals wholesale feed cache-scope isolation — a `wholesale_feed_version` minted under one `cache_scope` must not short-circuit (`unchanged: true`) a request the agent resolves to a different `cache_scope`. |
| `wholesale-feed-product-webhooks` | Account-level `notification_configs[]` registration for agents that advertise product wholesale feed webhook events. |
| `wholesale-feed-signal-webhooks` | Account-level `notification_configs[]` registration for agents that advertise signal wholesale feed webhook events. |
| `wholesale-feed-bulk-webhooks` | Account-level `notification_configs[]` registration for agents that advertise `wholesale_feed.bulk_change`. |
Expand Down
2 changes: 2 additions & 0 deletions docs/building/verification/conformance.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ Every agent MUST pass every storyboard below.
| [`notification_config_rejections`](https://adcontextprotocol.org/compliance/latest/universal/notification-config-rejections) | Semantic `notification_configs[]` request rejection path for duplicate `subscriber_id` values |
| [`wholesale_feed_products`](https://adcontextprotocol.org/compliance/latest/universal/wholesale-feed-products) | Product wholesale feed versioning: bootstrap responses carry `wholesale_feed_version`/`cache_scope`, and matching `if_wholesale_feed_version` probes return `unchanged` without product rows |
| [`wholesale_feed_signals`](https://adcontextprotocol.org/compliance/latest/universal/wholesale-feed-signals) | Signals wholesale feed versioning: bootstrap responses carry `wholesale_feed_version`/`cache_scope`, and matching `if_wholesale_feed_version` probes return `unchanged` without signal rows |
| [`wholesale_feed_products_scope_isolation`](https://adcontextprotocol.org/compliance/latest/universal/wholesale-feed-products-scope-isolation) | Product wholesale feed cache-scope isolation: a `wholesale_feed_version` minted under one `cache_scope` must not short-circuit (`unchanged: true`) a request the seller resolves to a different `cache_scope` |
| [`wholesale_feed_signals_scope_isolation`](https://adcontextprotocol.org/compliance/latest/universal/wholesale-feed-signals-scope-isolation) | Signals wholesale feed cache-scope isolation: a `wholesale_feed_version` minted under one `cache_scope` must not short-circuit (`unchanged: true`) a request the agent resolves to a different `cache_scope` |
| [`wholesale_feed_product_webhooks`](https://adcontextprotocol.org/compliance/latest/universal/wholesale-feed-product-webhooks) | Account-level `notification_configs[]` registration for agents that advertise product wholesale feed webhook events |
| [`wholesale_feed_signal_webhooks`](https://adcontextprotocol.org/compliance/latest/universal/wholesale-feed-signal-webhooks) | Account-level `notification_configs[]` registration for agents that advertise signal wholesale feed webhook events |
| [`wholesale_feed_bulk_webhooks`](https://adcontextprotocol.org/compliance/latest/universal/wholesale-feed-bulk-webhooks) | Account-level `notification_configs[]` registration for agents that advertise `wholesale_feed.bulk_change` |
Expand Down
20 changes: 14 additions & 6 deletions server/src/training-agent/task-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2248,18 +2248,26 @@ function stableMapDigest(map: Map<string, Record<string, unknown>>): string {
function productWholesaleFeedMeta(req: WholesaleFeedRequest, session: SessionState): WholesaleFeedMeta {
const seededProductsRevision = stableMapDigest(session.complyExtensions.seededProducts);
const seededPricingRevision = stableMapDigest(session.complyExtensions.seededPricingOptions);
const cacheScope = cacheScopeForWholesaleRequest(req);
// Tokens are scope-keyed: the same feed state yields a distinct token per
// cache_scope so a token minted under one scope never short-circuits a probe
// the seller resolves to another. See media-buy/get-products-response.json#unchanged.
return {
wholesale_feed_version: `${PRODUCT_WHOLESALE_FEED_VERSION}.${seededProductsRevision}`,
pricing_version: `${PRODUCT_WHOLESALE_PRICING_VERSION}.${seededPricingRevision}`,
cache_scope: cacheScopeForWholesaleRequest(req),
wholesale_feed_version: `${PRODUCT_WHOLESALE_FEED_VERSION}.${cacheScope}.${seededProductsRevision}`,
pricing_version: `${PRODUCT_WHOLESALE_PRICING_VERSION}.${cacheScope}.${seededPricingRevision}`,
cache_scope: cacheScope,
};
}

function signalWholesaleFeedMeta(req: WholesaleFeedRequest): WholesaleFeedMeta {
const cacheScope = cacheScopeForWholesaleRequest(req);
// Tokens are scope-keyed: the same feed state yields a distinct token per
// cache_scope so a token minted under one scope never short-circuits a probe
// the agent resolves to another. See signals/get-signals-response.json#unchanged.
return {
wholesale_feed_version: SIGNAL_WHOLESALE_FEED_VERSION,
pricing_version: SIGNAL_WHOLESALE_PRICING_VERSION,
cache_scope: cacheScopeForWholesaleRequest(req),
wholesale_feed_version: `${SIGNAL_WHOLESALE_FEED_VERSION}.${cacheScope}`,
pricing_version: `${SIGNAL_WHOLESALE_PRICING_VERSION}.${cacheScope}`,
cache_scope: cacheScope,
};
}

Expand Down
24 changes: 12 additions & 12 deletions server/tests/unit/training-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1221,8 +1221,8 @@ describe('get_products handler', () => {
buying_mode: 'wholesale',
});

expect(first.wholesale_feed_version).toBe('training-products-feed-v1.base');
expect(first.pricing_version).toBe('training-products-pricing-v1.base');
expect(first.wholesale_feed_version).toBe('training-products-feed-v1.public.base');
expect(first.pricing_version).toBe('training-products-pricing-v1.public.base');
expect(first.cache_scope).toBe('public');

const { result: unchanged } = await simulateCallTool(server, 'get_products', {
Expand All @@ -1242,14 +1242,14 @@ describe('get_products handler', () => {
const server = createTrainingAgentServer(DEFAULT_CTX);
const { result } = await simulateCallTool(server, 'get_products', {
buying_mode: 'wholesale',
if_wholesale_feed_version: 'training-products-feed-v1.base',
if_wholesale_feed_version: 'training-products-feed-v1.public.base',
if_pricing_version: 'stale-pricing-token',
});

expect(result.unchanged).toBeUndefined();
expect((result.products as unknown[]).length).toBeGreaterThan(0);
expect(result.wholesale_feed_version).toBe('training-products-feed-v1.base');
expect(result.pricing_version).toBe('training-products-pricing-v1.base');
expect(result.wholesale_feed_version).toBe('training-products-feed-v1.public.base');
expect(result.pricing_version).toBe('training-products-pricing-v1.public.base');
});

it('changes product wholesale version tokens when controller-seeded catalog state changes', async () => {
Expand Down Expand Up @@ -7239,8 +7239,8 @@ describe('get_signals handler', () => {
});

expect((first.signals as unknown[]).length).toBeGreaterThan(0);
expect(first.wholesale_feed_version).toBe('training-signals-feed-v1');
expect(first.pricing_version).toBe('training-signals-pricing-v1');
expect(first.wholesale_feed_version).toBe('training-signals-feed-v1.public');
expect(first.pricing_version).toBe('training-signals-pricing-v1.public');
expect(first.cache_scope).toBe('public');

const { result: unchanged } = await simulateCallTool(server, 'get_signals', {
Expand All @@ -7262,14 +7262,14 @@ describe('get_signals handler', () => {
const { result } = await simulateCallTool(server, 'get_signals', {
account,
discovery_mode: 'wholesale',
if_wholesale_feed_version: 'training-signals-feed-v1',
if_wholesale_feed_version: 'training-signals-feed-v1.public',
if_pricing_version: 'stale-pricing-token',
});

expect(result.unchanged).toBeUndefined();
expect((result.signals as unknown[]).length).toBeGreaterThan(0);
expect(result.wholesale_feed_version).toBe('training-signals-feed-v1');
expect(result.pricing_version).toBe('training-signals-pricing-v1');
expect(result.wholesale_feed_version).toBe('training-signals-feed-v1.public');
expect(result.pricing_version).toBe('training-signals-pricing-v1.public');
});

it('supports signal_refs exact lookup in brief mode', async () => {
Expand Down Expand Up @@ -7330,8 +7330,8 @@ describe('get_signals handler', () => {
signal_spec: 'E2E fallback signal discovery',
});

expect(result.wholesale_feed_version).toBe('training-signals-feed-v1');
expect(result.pricing_version).toBe('training-signals-pricing-v1');
expect(result.wholesale_feed_version).toBe('training-signals-feed-v1.public');
expect(result.pricing_version).toBe('training-signals-pricing-v1.public');
expect((result.signals as unknown[]).length).toBeGreaterThan(0);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
id: wholesale_feed_products_scope_isolation
version: "1.0.0"
title: "Wholesale product feed cache-scope isolation"
category: marketplace_catalog
summary: "Validates that get_products conditional-fetch is keyed by cache_scope: a wholesale_feed_version minted under one cache_scope must not short-circuit (unchanged: true) a request the seller resolves to a different cache_scope."
track: media_buy
introduced_in: "3.2"

requires_capability:
path: wholesale_feed_versioning.cache_scope_account
equals: true

required_tools:
- get_products

narrative: |
Wholesale feed tokens are scope-keyed: a buyer caches
`(cache_scope, wholesale_feed_version)` pairs, not a global seller version. A
seller that publishes per-account overlays (`cache_scope_account: true`)
therefore keeps distinct token spaces for the public rate card and for
account overlays.

This storyboard mints a token while the seller serves `cache_scope: public`,
then echoes that token on a request the seller resolves to `cache_scope:
account`. A scope-keyed comparator MUST reject the cross-scope token and
return the full feed for the resolved scope; a seller that keys conditional
fetch on a global token would wrongly answer `unchanged: true` and the buyer
would silently miss the account overlay's pricing.

agent:
interaction_model: media_buy_seller
capabilities:
- wholesale_feed_versioning
examples:
- "Sales agents that publish account-specific overlays"

caller:
role: buyer_agent
example: "Compliance test harness"

prerequisites:
description: |
The agent advertises `wholesale_feed_versioning.cache_scope_account: true`
and exposes `get_products`. The harness uses the reserved account-overlay
identity (`account-overlay.example`) to elicit a `cache_scope: account`
response and an ordinary account for the public layer.
test_kit: "test-kits/acme-outdoor.yaml"
controller_seeding: false

phases:
- id: scope_isolation
title: "Cross-scope token must not short-circuit"
steps:
- id: bootstrap_public
title: "Bootstrap public product feed"
task: get_products
schema_ref: "media-buy/get-products-request.json"
response_schema_ref: "media-buy/get-products-response.json"
doc_ref: "/media-buy/tasks/get_products"
stateful: true
context_outputs:
- name: public_product_feed_version
path: "wholesale_feed_version"
expected: |
Return public-scope product rows with the public feed token.
sample_request:
buying_mode: "wholesale"
account:
brand:
domain: "acmeoutdoor.example"
operator: "pinnacle-agency.example"
context:
correlation_id: "wholesale_feed_products_scope--bootstrap_public"
validations:
- check: response_schema
description: "Response matches get-products-response.json schema"
- check: field_value
path: "cache_scope"
value: "public"
description: "Ordinary account resolves to the public cache scope"
- check: field_present
path: "wholesale_feed_version"
description: "Public response carries the public feed token"
- check: field_present
path: "products[0].product_id"
description: "Bootstrap returns product rows"
- check: field_value
path: "context.correlation_id"
value: "wholesale_feed_products_scope--bootstrap_public"
description: "Context correlation_id returned unchanged"

- id: cross_scope_probe_not_unchanged
title: "Account-scope probe must not honor a public token"
task: get_products
schema_ref: "media-buy/get-products-request.json"
response_schema_ref: "media-buy/get-products-response.json"
doc_ref: "/media-buy/tasks/get_products"
stateful: true
expected: |
An account-scope request that echoes the public token MUST NOT return
unchanged; it MUST return the full account feed for its own scope.
sample_request:
buying_mode: "wholesale"
account:
brand:
domain: "account-overlay.example"
operator: "pinnacle-agency.example"
if_wholesale_feed_version: "$context.public_product_feed_version"
context:
correlation_id: "wholesale_feed_products_scope--cross_probe"
validations:
- check: response_schema
description: "Response matches get-products-response.json schema"
- check: field_value
path: "cache_scope"
value: "account"
description: "Reserved overlay account resolves to the account cache scope"
- check: field_absent
path: "unchanged"
description: "A public-scope token MUST NOT short-circuit an account-scope read to unchanged: true"
- check: field_present
path: "products[0].product_id"
description: "The account feed is returned in full for its own scope"
- check: field_value
path: "context.correlation_id"
value: "wholesale_feed_products_scope--cross_probe"
description: "Context correlation_id returned unchanged"
Loading
Loading