Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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.
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
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"
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
id: wholesale_feed_signals_scope_isolation
version: "1.0.0"
title: "Wholesale signals feed cache-scope isolation"
category: marketplace_catalog
summary: "Validates that get_signals 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 agent resolves to a different cache_scope."
track: signals
introduced_in: "3.2"

requires_capability:
path: wholesale_feed_versioning.cache_scope_account
equals: true

required_tools:
- get_signals

narrative: |
Wholesale feed tokens are scope-keyed: a caller caches
`(cache_scope, wholesale_feed_version)` pairs, not a global agent version. An
agent 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 agent serves `cache_scope: public`,
then echoes that token on a request the agent resolves to `cache_scope:
account`. A scope-keyed comparator MUST reject the cross-scope token and
return the full feed for the resolved scope; an agent that keys conditional
fetch on a global token would wrongly answer `unchanged: true` and the caller
would silently miss the account overlay.

agent:
interaction_model: signals_agent
capabilities:
- wholesale_feed_versioning
examples:
- "Signals 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_signals`. 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 signals feed"
task: get_signals
schema_ref: "signals/get-signals-request.json"
response_schema_ref: "signals/get-signals-response.json"
doc_ref: "/signals/tasks/get_signals"
stateful: true
context_outputs:
- name: public_signal_feed_version
path: "wholesale_feed_version"
expected: |
Return public-scope signal rows with the public feed token.
sample_request:
discovery_mode: "wholesale"
account:
brand:
domain: "acmeoutdoor.example"
operator: "pinnacle-agency.example"
context:
correlation_id: "wholesale_feed_signals_scope--bootstrap_public"
validations:
- check: response_schema
description: "Response matches get-signals-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: "signals[0].signal_agent_segment_id"
description: "Bootstrap returns signal rows"
- check: field_value
path: "context.correlation_id"
value: "wholesale_feed_signals_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_signals
schema_ref: "signals/get-signals-request.json"
response_schema_ref: "signals/get-signals-response.json"
doc_ref: "/signals/tasks/get_signals"
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:
discovery_mode: "wholesale"
account:
brand:
domain: "account-overlay.example"
operator: "pinnacle-agency.example"
if_wholesale_feed_version: "$context.public_signal_feed_version"
context:
correlation_id: "wholesale_feed_signals_scope--cross_probe"
validations:
- check: response_schema
description: "Response matches get-signals-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: "signals[0].signal_agent_segment_id"
description: "The account feed is returned in full for its own scope"
- check: field_value
path: "context.correlation_id"
value: "wholesale_feed_signals_scope--cross_probe"
description: "Context correlation_id returned unchanged"
2 changes: 1 addition & 1 deletion static/schemas/source/media-buy/get-products-response.json
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@
"unchanged": {
"type": "boolean",
"const": true,
"description": "Present and `true` ONLY on wholesale-mode responses when the request carried if_wholesale_feed_version (and/or if_pricing_version) matching the seller's current version for the buyer's cache_scope, in which case products[] MUST be omitted; wholesale_feed_version (echoed), cache_scope (echoed), and pricing_version (echoed when used) MUST still be present. Buyers receiving unchanged: true MUST NOT mutate their local wholesale product mirror. **One shape per state:** sellers MUST NOT emit `unchanged: false` — the absence of the field IS the signal that the response carries products. Two shapes ({ unchanged: false, products: [...] } vs. { products: [...] }) for the same state would let some sellers always emit the field and some never would, creating an inconsistency the wire shouldn't carry."
"description": "Present and `true` ONLY on wholesale-mode responses when the request carried if_wholesale_feed_version (and/or if_pricing_version) matching the seller's current version for the buyer's cache_scope, in which case products[] MUST be omitted; wholesale_feed_version (echoed), cache_scope (echoed), and pricing_version (echoed when used) MUST still be present. Buyers receiving unchanged: true MUST NOT mutate their local wholesale product mirror. **One shape per state:** sellers MUST NOT emit `unchanged: false` — the absence of the field IS the signal that the response carries products. Two shapes ({ unchanged: false, products: [...] } vs. { products: [...] }) for the same state would let some sellers always emit the field and some never would, creating an inconsistency the wire shouldn't carry. **Cross-scope isolation:** the comparator that decides `unchanged` MUST be keyed on `(cache_scope, wholesale_feed_version)`, not on the token value alone. A seller MUST NOT emit `unchanged: true` when it resolves the request to a different `cache_scope` than the one whose token the buyer echoed in `if_wholesale_feed_version` (and/or `if_pricing_version`): because the token is scope-keyed, a value minted for `cache_scope: 'public'` cannot match the seller's current token for `cache_scope: 'account'` (or vice-versa), so such a request MUST return the full feed for the resolved scope with that scope's own token."
},
"sandbox": {
"type": "boolean",
Expand Down
2 changes: 1 addition & 1 deletion static/schemas/source/signals/get-signals-response.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@
"unchanged": {
"type": "boolean",
"const": true,
"description": "Present and `true` ONLY on wholesale-mode responses when the request carried if_wholesale_feed_version (and/or if_pricing_version) matching the agent's current version for the caller's cache_scope, in which case signals[] MUST be omitted; wholesale_feed_version (echoed), cache_scope (echoed), and pricing_version (echoed when used) MUST still be present. Callers receiving unchanged: true MUST NOT mutate their local wholesale signals mirror. **One shape per state:** agents MUST NOT emit `unchanged: false` — the absence of the field IS the signal that the response carries signals."
"description": "Present and `true` ONLY on wholesale-mode responses when the request carried if_wholesale_feed_version (and/or if_pricing_version) matching the agent's current version for the caller's cache_scope, in which case signals[] MUST be omitted; wholesale_feed_version (echoed), cache_scope (echoed), and pricing_version (echoed when used) MUST still be present. Callers receiving unchanged: true MUST NOT mutate their local wholesale signals mirror. **One shape per state:** agents MUST NOT emit `unchanged: false` — the absence of the field IS the signal that the response carries signals. **Cross-scope isolation:** the comparator that decides `unchanged` MUST be keyed on `(cache_scope, wholesale_feed_version)`, not on the token value alone. An agent MUST NOT emit `unchanged: true` when it resolves the request to a different `cache_scope` than the one whose token the caller echoed in `if_wholesale_feed_version` (and/or `if_pricing_version`): because the token is scope-keyed, a value minted for `cache_scope: 'public'` cannot match the agent's current token for `cache_scope: 'account'` (or vice-versa), so such a request MUST return the full feed for the resolved scope with that scope's own token."
},
"pagination": {
"$ref": "/schemas/core/pagination-response.json"
Expand Down
Loading