{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "$id": "/schemas/3.1.0-rc.4/media-buy/get-products-response.json",
  "title": "Get Products Response",
  "description": "Response payload for get_products task",
  "type": "object",
  "allOf": [
    {
      "$ref": "/schemas/3.1.0-rc.4/core/version-envelope.json"
    },
    {
      "$ref": "/schemas/3.1.0-rc.4/core/protocol-envelope.json"
    }
  ],
  "properties": {
    "products": {
      "type": "array",
      "description": "Array of matching products",
      "items": {
        "$ref": "/schemas/3.1.0-rc.4/core/product.json"
      }
    },
    "extensions": {
      "type": "object",
      "description": "Bundled platform-extension definitions referenced by any product in `products`. Keyed by `<extension_uri>@<digest>` (e.g., `https://creative.adcontextprotocol.org/translated/meta/extensions/meta_pixel@sha256:abc...`). When present, lets buyers resolve `platform_extensions` references on product format declarations without a separate fetch. Buyer SDKs cache by URI@digest; subsequent get_products responses MAY omit definitions the buyer already has cached and rely on the digest match. Each value is an extension definition with `extends` (the canonical concept it extends, e.g., `tracking`), `fields` (the schema for additional fields the extension contributes), `version`, and optional `description`.",
      "patternProperties": {
        "^https?://[^@]+@sha256:[a-f0-9]{64}$": {
          "type": "object",
          "required": [
            "extends",
            "fields"
          ],
          "properties": {
            "extends": {
              "type": "string",
              "description": "Canonical concept this extension extends (e.g., `tracking`, `cta_vocabulary`, `destinations`, `placement`)."
            },
            "fields": {
              "type": "object",
              "description": "JSON Schema fragment declaring the additional fields this extension contributes."
            },
            "version": {
              "type": "string",
              "description": "Semantic version of the extension definition. Distinct from the digest — version is human-readable; digest is the integrity check."
            },
            "description": {
              "type": "string"
            }
          },
          "additionalProperties": true
        }
      },
      "additionalProperties": true
    },
    "proposals": {
      "type": "array",
      "description": "Optional array of proposed media plans with budget allocations across products. Publishers include proposals when they can provide strategic guidance based on the brief. Proposals are actionable - buyers can refine them via follow-up get_products calls within the same session, or execute them directly via create_media_buy.",
      "items": {
        "$ref": "/schemas/3.1.0-rc.4/core/proposal.json"
      }
    },
    "errors": {
      "type": "array",
      "description": "Task-specific errors and warnings (e.g., product filtering issues)",
      "items": {
        "$ref": "/schemas/3.1.0-rc.4/core/error.json"
      }
    },
    "property_list_applied": {
      "type": "boolean",
      "description": "[AdCP 3.0] Indicates whether property_list filtering was applied. True if the agent filtered products based on the provided property_list. Absent or false if property_list was not provided or not supported by this agent."
    },
    "catalog_applied": {
      "type": "boolean",
      "description": "Whether the seller filtered results based on the provided catalog. True if the seller matched catalog items against its inventory. Absent or false if no catalog was provided or the seller does not support catalog matching."
    },
    "refinement_applied": {
      "type": "array",
      "description": "Seller's response to each change request in the refine array, matched by position. Each entry acknowledges whether the corresponding ask was applied, partially applied, or unable to be fulfilled. MUST contain the same number of entries in the same order as the request's refine array. Only present when the request used buying_mode: 'refine'. Each entry MUST echo the request entry's scope and — for product and proposal scopes — the matching id field (product_id or proposal_id), so orchestrators can cross-validate alignment.",
      "items": {
        "type": "object",
        "discriminator": {
          "propertyName": "scope"
        },
        "oneOf": [
          {
            "properties": {
              "scope": {
                "type": "string",
                "const": "request",
                "description": "Echoes scope 'request' from the corresponding refine entry."
              },
              "status": {
                "type": "string",
                "enum": [
                  "applied",
                  "partial",
                  "unable"
                ],
                "description": "'applied': the ask was fulfilled. 'partial': the ask was partially fulfilled — see notes for details. 'unable': the seller could not fulfill the ask — see notes for why."
              },
              "notes": {
                "type": "string",
                "description": "Seller explanation of what was done, what couldn't be done, or why. Recommended when status is 'partial' or 'unable'."
              }
            },
            "required": [
              "scope",
              "status"
            ],
            "additionalProperties": false
          },
          {
            "properties": {
              "scope": {
                "type": "string",
                "const": "product",
                "description": "Echoes scope 'product' from the corresponding refine entry."
              },
              "product_id": {
                "type": "string",
                "description": "Echoes product_id from the corresponding refine entry."
              },
              "status": {
                "type": "string",
                "enum": [
                  "applied",
                  "partial",
                  "unable"
                ],
                "description": "'applied': the ask was fulfilled. 'partial': the ask was partially fulfilled — see notes for details. 'unable': the seller could not fulfill the ask — see notes for why."
              },
              "notes": {
                "type": "string",
                "description": "Seller explanation of what was done, what couldn't be done, or why. Recommended when status is 'partial' or 'unable'."
              }
            },
            "required": [
              "scope",
              "product_id",
              "status"
            ],
            "additionalProperties": false
          },
          {
            "properties": {
              "scope": {
                "type": "string",
                "const": "proposal",
                "description": "Echoes scope 'proposal' from the corresponding refine entry."
              },
              "proposal_id": {
                "type": "string",
                "description": "Echoes proposal_id from the corresponding refine entry."
              },
              "status": {
                "type": "string",
                "enum": [
                  "applied",
                  "partial",
                  "unable"
                ],
                "description": "'applied': the ask was fulfilled. 'partial': the ask was partially fulfilled — see notes for details. 'unable': the seller could not fulfill the ask — see notes for why."
              },
              "notes": {
                "type": "string",
                "description": "Seller explanation of what was done, what couldn't be done, or why. Recommended when status is 'partial' or 'unable'."
              }
            },
            "required": [
              "scope",
              "proposal_id",
              "status"
            ],
            "additionalProperties": false
          }
        ]
      }
    },
    "incomplete": {
      "type": "array",
      "description": "Declares what the seller could not finish within the buyer's time_budget or due to internal limits. Each entry identifies a scope that is missing or partial. Absent when the response is fully complete.",
      "minItems": 1,
      "items": {
        "type": "object",
        "properties": {
          "scope": {
            "type": "string",
            "enum": [
              "products",
              "pricing",
              "forecast",
              "proposals",
              "wholesale_feed"
            ],
            "description": "'products': not all inventory sources were searched. 'pricing': products returned but pricing is absent or unconfirmed. 'forecast': products returned but forecast data is absent. 'proposals': proposals were not generated or are incomplete. 'wholesale_feed': in wholesale mode, full feed enumeration could not complete in the time budget — symmetric with get_signals' 'wholesale_feed' scope so sellers have a precise way to declare wholesale-incomplete on the products surface."
          },
          "description": {
            "type": "string",
            "description": "Human-readable explanation of what is missing and why."
          },
          "estimated_wait": {
            "allOf": [
              {
                "$ref": "/schemas/3.1.0-rc.4/core/duration.json"
              }
            ],
            "description": "How much additional time would resolve this scope. Allows the buyer to decide whether to retry with a larger time_budget."
          }
        },
        "required": [
          "scope",
          "description"
        ],
        "additionalProperties": false
      }
    },
    "filter_diagnostics": {
      "type": "object",
      "description": "Optional non-fatal diagnostic block describing how the request's `filters` narrowed the candidate set. Use this to disambiguate empty/small result lists between 'no inventory matches the brief' and 'a specific filter excluded everything', without breaking the filter-not-fail convention (sellers still silently exclude unmatched products; this block is observability, not error reporting). Sellers MAY populate this when meaningful narrowing occurred; buyers MAY use it for triage UX without depending on its presence. Counts only — products are not enumerated by name to avoid leaking competitive intelligence about adjacent campaigns or seller inventory. `total_candidates` and `excluded_by` are independently optional — sellers whose baseline candidate set size is sensitive MAY emit `excluded_by` without `total_candidates`, or vice versa.",
      "properties": {
        "semantics": {
          "type": "string",
          "enum": [
            "only",
            "any",
            "approximate"
          ],
          "description": "How `excluded_by[*].count` values are computed across multiple filters. `only`: counts products that would have been included if not for THIS filter alone (deterministic; the right value for 'which filter killed my result set' triage — recommended when feasible). `any`: counts products excluded by ANY filter (so multiple filters' counts may overlap and sum to more than `total_candidates`). `approximate`: sellers SHOULD use this when their pipeline can't cleanly attribute exclusions to a single filter. Buyers SHOULD inspect `semantics` before doing arithmetic on counts."
        },
        "total_candidates": {
          "type": "integer",
          "description": "Number of products the seller considered before applying `filters`. Baseline for interpreting per-filter exclusion counts. Approximate — sellers MAY return a sampled or capped count when their candidate pool is large. Optional; sellers whose baseline candidate set size is sensitive (revealing market posture or competitive density) MAY omit this while still emitting `excluded_by`.",
          "minimum": 0
        },
        "excluded_by": {
          "type": "object",
          "description": "Per-filter exclusion counts, keyed by the filter property name as it appears in the request's `filters` object (e.g., `pricing_currencies`, `required_metrics`, `required_vendor_metrics`, `required_geo_targeting`, `budget_range`). Values are objects carrying `count` and optional filter-specific detail. Only filters that actually narrowed the set need appear here; absence of a key means that filter did not exclude anything (or was not in the request).",
          "additionalProperties": {
            "type": "object",
            "properties": {
              "count": {
                "type": "integer",
                "description": "Number of products excluded by this filter, interpreted per the parent `semantics` field.",
                "minimum": 0
              },
              "values": {
                "type": "array",
                "description": "Optional list of the specific filter values that contributed to exclusions, when meaningful. For `required_metrics`: the metric names that excluded products (strings). For `required_vendor_metrics`: the vendor/metric pin entries (objects). Item shape is filter-specific; the schema admits string OR object items. Buyers without filter-specific knowledge SHOULD treat as opaque.",
                "items": {
                  "oneOf": [
                    {
                      "type": "string"
                    },
                    {
                      "type": "object"
                    }
                  ]
                }
              },
              "notes": {
                "type": "string",
                "description": "Optional human-readable note about why this filter narrowed the set (e.g., 'no products in this brief support DV viewability at the requested threshold')."
              }
            },
            "required": [
              "count"
            ],
            "additionalProperties": false
          }
        }
      },
      "additionalProperties": true,
      "examples": [
        {
          "semantics": "only",
          "total_candidates": 47,
          "excluded_by": {
            "required_metrics": {
              "count": 31,
              "values": [
                "completed_views"
              ]
            },
            "required_geo_targeting": {
              "count": 9
            },
            "pricing_currencies": {
              "count": 3,
              "values": [
                "USD"
              ]
            },
            "budget_range": {
              "count": 7
            }
          }
        }
      ]
    },
    "pagination": {
      "$ref": "/schemas/3.1.0-rc.4/core/pagination-response.json"
    },
    "wholesale_feed_version": {
      "type": "string",
      "description": "Opaque token representing the version of the wholesale product feed state used to compose this response. Sellers that implement conditional-fetch (if_wholesale_feed_version) MUST return this on every wholesale-mode response so buyers can cache and probe later. Buyers MUST treat the value as opaque — no format, no ordering, no inspection. The token is scope-keyed: it describes a version for the cache_scope declared on this response, NOT a global agent version. A buyer caches `(cache_scope, wholesale_feed_version)` pairs and presents the matching token on the next request. Scoping dimensions: (agent, buying_mode, filters, property_list, catalog) for cache_scope: 'public'; that tuple plus account_id for cache_scope: 'account'. pagination.cursor is NOT part of the scoping tuple. See specs/wholesale-feed-webhooks.md for the full cache layering model.",
      "x-adcp-validation": {
        "verifier_constraints": {
          "required_for_wholesale_request": {
            "task": "get_products",
            "request_field": "buying_mode",
            "equals": "wholesale"
          }
        },
        "spec": "specs/wholesale-feed-webhooks.md#consumer-pattern"
      }
    },
    "pricing_version": {
      "type": "string",
      "description": "Opaque token representing the version of the pricing layer, including product pricing_options and nested signal_targeting_options pricing_options. When the seller supports independent pricing versioning, pricing_version changes when prices move but wholesale_feed_version changes only when structure/metadata moves. Same cache_scope keying as wholesale_feed_version. Sellers not separating these MAY omit pricing_version and use wholesale_feed_version for both."
    },
    "cache_scope": {
      "type": "string",
      "enum": [
        "public",
        "account"
      ],
      "description": "Declares whether the wholesale_feed_version and pricing_version on this response describe a universal layer or an account-specific overlay. REQUIRED on every 3.1+ response (the 3.1 schema enforces this — the safety property of the two-layer cache model depends on it). 'public': this response describes the seller's published rate card; the buyer MAY dedupe under (agent, buying_mode, filters, property_list, catalog) without scoping by account. 'account': this response includes account-specific overrides; the buyer MUST cache the version under (agent, buying_mode, filters, property_list, catalog, account_id). When the request did NOT include `account`, the seller MUST return `cache_scope: 'public'`. When the request included `account`, the seller MUST return either: 'public' (this account prices off the public rate card — buyer dedupes) or 'account' (account-specific overrides exist — buyer caches under the account key). Sellers MAY return 'public' on an account-scoped request that previously had overrides — buyers SHOULD interpret this as a downgrade and drop their account-overlay for the (agent, filters, mode) tuple. Without schema-required cache_scope, a seller silently omitting the field on an account-scoped response would cause buyers to mis-key the cache and serve account-overlay payloads to other accounts — the canonical safety invariant of the entire cache layering model. **Backward-compatibility note for 3.1 validators:** SDKs that validate strictly against the 3.1 schema MUST select the validator based on the server-declared `adcp_version` (release-precision version negotiation, 3.1). For responses with `adcp_version` starting `3.0`, the 3.1 cache_scope-required constraint MUST be relaxed — pre-3.1 sellers correctly emit no cache_scope and remain conformant to their declared version. This is a tightening within 3.1, not a 3.0 break."
    },
    "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."
    },
    "sandbox": {
      "type": "boolean",
      "description": "When true, this response contains simulated data from sandbox mode."
    },
    "context": {
      "$ref": "/schemas/3.1.0-rc.4/core/context.json"
    },
    "ext": {
      "$ref": "/schemas/3.1.0-rc.4/core/ext.json"
    }
  },
  "if": {
    "properties": {
      "unchanged": {
        "const": true
      }
    },
    "required": [
      "unchanged"
    ]
  },
  "then": {
    "description": "Wholesale-feed unchanged response: products MUST be omitted; wholesale_feed_version and cache_scope MUST be present (echoed from the cached version).",
    "required": [
      "wholesale_feed_version",
      "cache_scope"
    ],
    "not": {
      "required": [
        "products"
      ]
    }
  },
  "else": {
    "description": "Standard response: products[] MUST be present and cache_scope MUST declare the response's cache layer. wholesale_feed_version is required by the wholesale-mode contract and verifier constraints, but is not schema-required here because this response schema is shared by non-wholesale reads.",
    "required": [
      "products",
      "cache_scope"
    ]
  },
  "additionalProperties": true
}
