From 6503c7041e5b4c8a5bac807195fa3a6c9e1cef4a Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 30 Jul 2026 18:10:04 -0400 Subject: [PATCH 01/49] fix(security): make get_products retry-safe --- .changeset/secure-get-products-idempotency.md | 5 + docs/building/by-layer/L0/a2a-guide.mdx | 11 +- docs/building/by-layer/L0/mcp-guide.mdx | 10 + docs/building/by-layer/L1/security.mdx | 4 +- .../building/by-layer/L2/context-sessions.mdx | 4 + docs/building/by-layer/L4/build-a-caller.mdx | 2 + docs/building/by-layer/L4/choose-your-sdk.mdx | 14 +- .../concepts/how-agents-communicate.mdx | 1 + .../concepts/managing-response-size.mdx | 2 + .../cross-cutting/version-adaptation.mdx | 18 +- .../building/operating/seller-integration.mdx | 1 + docs/contributing/testable-snippets.md | 14 +- .../property/authorized-properties.mdx | 6 +- docs/intro.mdx | 2 + .../a2b-testing-your-first-agent.mdx | 1 + .../buyer-briefs-and-get-products.mdx | 2 + docs/learning/tracks/buyer.mdx | 2 +- .../advanced-topics/accountability.mdx | 1 + docs/media-buy/advanced-topics/sandbox.mdx | 2 + docs/media-buy/conversion-tracking/index.mdx | 1 + docs/media-buy/creatives/index.mdx | 2 + docs/media-buy/index.mdx | 2 + .../product-discovery/brief-expectations.mdx | 8 + .../collections-and-installments.mdx | 1 + .../product-discovery/example-briefs.mdx | 11 +- .../product-discovery/media-products.mdx | 2 + .../product-discovery/refinement.mdx | 7 + docs/media-buy/specification.mdx | 9 +- .../media-buy/task-reference/get_products.mdx | 47 +- docs/protocol/get_adcp_capabilities.mdx | 4 + docs/quickstart.mdx | 4 + docs/reference/implementor-faq.mdx | 6 +- docs/reference/release-notes.mdx | 4 +- docs/reference/whats-new-in-3-1.mdx | 2 +- docs/reference/whats-new-in-v3.mdx | 1 + docs/sponsored-intelligence/overview.mdx | 1 + docs/sponsored-intelligence/workflow.mdx | 1 + scripts/build-compliance.cjs | 11 +- scripts/build-schemas.cjs | 7 +- server/src/addie/mcp/adcp-tools.ts | 19 +- server/src/addie/mcp/member-tools.ts | 21 +- server/src/http.ts | 5 +- server/src/mcp-tools.ts | 13 +- server/src/routes/registry-api.ts | 10 +- server/src/training-agent/idempotency.ts | 1 + server/src/training-agent/task-handlers.ts | 470 ++++++++++++++-- server/src/training-agent/tenants/router.ts | 49 +- .../tenants/tenant-smoke.test.ts | 202 +++++++ .../src/training-agent/v6-sales-platform.ts | 44 +- server/tests/unit/addie/adcp-tools.test.ts | 38 ++ server/tests/unit/idempotency.test.ts | 2 +- server/tests/unit/mcp-eval-tools.test.ts | 102 +++- .../unit/training-agent-idempotency.test.ts | 511 +++++++++++++++++- server/tests/unit/training-agent.test.ts | 50 +- .../source/protocols/governance/index.yaml | 1 + .../source/protocols/media-buy/index.yaml | 1 + .../scenarios/audience_buy_flow.yaml | 1 + .../scenarios/available_actions.yaml | 1 + .../scenarios/canonical_formats.yaml | 7 + .../media-buy/scenarios/clicks_buy_flow.yaml | 1 + .../scenarios/completed_views_buy_flow.yaml | 1 + .../creative_fate_after_cancellation.yaml | 1 + .../scenarios/delivery_reporting.yaml | 1 + .../scenarios/demographic_targeting.yaml | 1 + .../scenarios/dependency_impairment.yaml | 1 + .../dependency_impairment_cardinality.yaml | 1 + .../media-buy/scenarios/event_dedup_flow.yaml | 1 + .../scenarios/frequency_cap_enforcement.yaml | 1 + .../scenarios/get_products_async.yaml | 80 ++- .../scenarios/governance_approved.yaml | 1 + .../scenarios/governance_conditions.yaml | 1 + .../scenarios/governance_denied.yaml | 1 + .../scenarios/governance_denied_recovery.yaml | 1 + .../inline_creatives_without_sync.yaml | 2 + .../scenarios/invalid_transitions.yaml | 1 + .../scenarios/inventory_list_no_match.yaml | 1 + .../scenarios/inventory_list_targeting.yaml | 1 + .../scenarios/measurement_accountability.yaml | 1 + .../scenarios/measurement_terms_rejected.yaml | 1 + .../scenarios/pending_creatives_to_start.yaml | 1 + .../per_creative_conversion_attribution.yaml | 1 + .../scenarios/performance_buy_flow.yaml | 1 + .../scenarios/performance_buy_flow_roas.yaml | 1 + .../scenarios/pricing_currency_filter.yaml | 1 + .../scenarios/product_signal_targeting.yaml | 1 + .../scenarios/proposal_finalize.yaml | 50 ++ .../proposal_finalize_asap_timing.yaml | 3 + .../scenarios/proposal_not_found_errors.yaml | 1 + .../provenance_audit_observation.yaml | 1 + .../scenarios/provenance_enforcement.yaml | 1 + .../scenarios/provenance_truth_of_claim.yaml | 1 + .../media-buy/scenarios/reach_buy_flow.yaml | 1 + .../refine_finalize_exclusivity.yaml | 6 + .../media-buy/scenarios/refine_products.yaml | 2 + .../vendor_metric_accountability.yaml | 1 + .../vendor_metric_optimization_flow.yaml | 1 + .../protocols/media-buy/state-machine.yaml | 1 + .../generative-seller.yaml | 1 + .../specialisms/sales-broadcast-tv/index.yaml | 1 + .../sales-catalog-driven/index.yaml | 1 + .../specialisms/sales-guaranteed/index.yaml | 1 + .../sales-non-guaranteed/index.yaml | 1 + .../sales-proposal-mode/index.yaml | 3 + .../source/universal/error-compliance.yaml | 4 + .../get-products-pagination-integrity.yaml | 2 + .../universal/read-tool-idempotency.yaml | 7 +- .../source/universal/schema-validation.yaml | 3 + .../universal/stale-response-advisory.yaml | 2 + .../source/universal/webhook-emission.yaml | 2 + .../universal/wholesale-feed-products.yaml | 3 + static/schemas/source/manifest.schema.json | 2 +- .../media-buy/get-products-request.json | 9 + tests/composed-schema-validation.test.cjs | 1 + tests/example-validation-simple.test.cjs | 4 + tests/example-validation.test.cjs | 1 + tests/lint-storyboard-contradictions.test.cjs | 2 +- 116 files changed, 1869 insertions(+), 137 deletions(-) create mode 100644 .changeset/secure-get-products-idempotency.md diff --git a/.changeset/secure-get-products-idempotency.md b/.changeset/secure-get-products-idempotency.md new file mode 100644 index 0000000000..14d363d060 --- /dev/null +++ b/.changeset/secure-get-products-idempotency.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": patch +--- + +Require idempotency keys for polymorphic `get_products` requests so asynchronous discovery and proposal finalization are safe to retry. diff --git a/docs/building/by-layer/L0/a2a-guide.mdx b/docs/building/by-layer/L0/a2a-guide.mdx index 79c9980e2b..8e1e0c225b 100644 --- a/docs/building/by-layer/L0/a2a-guide.mdx +++ b/docs/building/by-layer/L0/a2a-guide.mdx @@ -964,7 +964,16 @@ const result = await a2a.send({ role: "ROLE_USER", parts: [ { text: "Find luxury car inventory" }, - { data: { skill: "get_products", parameters: { audience: "luxury car intenders" } } } + { + data: { + skill: "get_products", + parameters: { + idempotency_key: "550e8400-e29b-41d4-a716-446655442071", + buying_mode: "brief", + brief: "Luxury car inventory for in-market shoppers" + } + } + } ] } }); diff --git a/docs/building/by-layer/L0/mcp-guide.mdx b/docs/building/by-layer/L0/mcp-guide.mdx index 4f1699e39f..e16b4417d4 100644 --- a/docs/building/by-layer/L0/mcp-guide.mdx +++ b/docs/building/by-layer/L0/mcp-guide.mdx @@ -18,6 +18,8 @@ You can test AdCP tasks using the [CLI tools](/docs/building/by-layer/L4/choose- ```javascript // Standard MCP tool call const response = await mcp.call('get_products', { + idempotency_key: "550e8400-e29b-41d4-a716-446655442072", + buying_mode: "brief", brand: { domain: "premiumpetfoods.com" }, @@ -34,6 +36,8 @@ console.log(response.message); // Human-readable summary ```javascript // Structured parameters const response = await mcp.call('get_products', { + idempotency_key: "550e8400-e29b-41d4-a716-446655442073", + buying_mode: "brief", brand: { domain: "betnow.com" }, @@ -247,6 +251,7 @@ Include the `task` field in your `tools/call` request: "params": { "name": "get_products", "arguments": { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442005", "buying_mode": "brief", "brief": "Premium CTV inventory for luxury auto" }, @@ -386,11 +391,15 @@ const session = new McpAdcpSession(mcp); // First call - no context needed const products = await session.call('get_products', { + idempotency_key: "550e8400-e29b-41d4-a716-446655442074", + buying_mode: "brief", brief: "Sports campaign" }); // Follow-up - context automatically included const refined = await session.call('get_products', { + idempotency_key: "550e8400-e29b-41d4-a716-446655442075", + buying_mode: "brief", brief: "Focus on premium CTV" }); // Session remembers previous interaction @@ -405,6 +414,7 @@ const session = new McpAdcpSession(mcp); // Synchronous call (no task augmentation) const products = await session.call('get_products', { + idempotency_key: '550e8400-e29b-41d4-a716-446655442006', buying_mode: 'brief', brief: "Sports campaign" }); diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index 4a83fe6a2a..c28553b8bc 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -401,7 +401,7 @@ A daypart with no declared semantics is ambiguous and MUST be rejected with `INV **Enforcement curve.** Sellers MUST reject any **mutating** request that omits `idempotency_key` with `INVALID_REQUEST` from 3.0 onward (unchanged). For **read** requests, the rule phases in across two minors: -- **3.1.0** — sellers MUST accept reads that carry `idempotency_key` and process per rules 2–9 (no rejecting on undeclared envelope fields). Sellers SHOULD reject reads that omit it with `INVALID_REQUEST`; sellers MAY accept the omission for the 3.1.x maintenance window. +- **3.1.0** — sellers MUST accept reads that carry `idempotency_key` and process per rules 2–9 (no rejecting on undeclared envelope fields). Sellers SHOULD reject reads that omit it with `INVALID_REQUEST`; sellers MAY accept the omission for the 3.1.x maintenance window **only when the request completes synchronously without creating a task or changing state**. A polymorphic request that would return `Submitted` or commit/finalize state MUST reject an omitted key before performing that effect. - **3.2.0** — sellers MUST reject reads that omit `idempotency_key` with `INVALID_REQUEST`. The grace window closes at the 3.2 cut. This staged enforcement lets hand-rolled buyer integrations — built via curl, thin MCP clients, or OpenAPI codegen that doesn't include the field uniformly — migrate over a release window rather than at the 3.1 cut. Buyer SDKs (`@adcp/client`, `adcp-py`) already send `idempotency_key` uniformly today, so SDK-using integrators are unaffected by the cut date. @@ -412,7 +412,7 @@ This section applies only to AdCP task requests. OpenRTB bid streams have their #### Normative seller behavior -1. **Schema validation runs first.** Sellers MUST validate the request against its schema (including presence and format of `idempotency_key`) BEFORE consulting the idempotency cache. A malformed request returns `INVALID_REQUEST` without ever touching the cache — otherwise cache misses become a timing side channel that leaks whether schema validation accepted the key format. Validation errors are never cached (per rule 2). +1. **Schema validation runs first.** Sellers MUST validate the request against its schema (including presence and format of `idempotency_key`) BEFORE consulting the idempotency cache. The sole 3.1.x migration exception is an omitted key on a request that the seller can guarantee will complete as a side-effect-free synchronous read; this exception never permits `Submitted`, task allocation, or a state transition. A malformed request returns `INVALID_REQUEST` without ever touching the cache — otherwise cache misses become a timing side channel that leaks whether schema validation accepted the key format. Validation errors are never cached (per rule 2). 2. **First call is canonical.** On **task success** (`status: completed` or `status: submitted` for async operations), the seller stores the inner response payload (not the protocol envelope) keyed by `(authenticated_agent, account_id, idempotency_key)` along with a hash of the canonical request payload. **The cache entry is immutable** — replays within the TTL MUST return the originally-cached payload (with `replayed: true`), and state-tracking fields in that payload MUST NOT be refreshed to reflect the resource's current state. This rule applies across both success branches: - **Async tasks** — the cached response is the `submitted` result containing `task_id`. Even if the async task subsequently completes, fails, or is canceled, a replay MUST return the originally-cached `submitted` response, NOT the current terminal state. The buyer uses the returned `task_id` to observe current state via `tasks/get` or webhook, exactly as it would have on the first call. diff --git a/docs/building/by-layer/L2/context-sessions.mdx b/docs/building/by-layer/L2/context-sessions.mdx index fc5cf19633..4f4cc9b9bc 100644 --- a/docs/building/by-layer/L2/context-sessions.mdx +++ b/docs/building/by-layer/L2/context-sessions.mdx @@ -82,12 +82,16 @@ MCP requires explicit context management to maintain state: ```javascript // First call - no context const result1 = await mcp.call('get_products', { + idempotency_key: "550e8400-e29b-41d4-a716-446655442076", + buying_mode: "brief", brief: "Video ads" }); const contextId = result1.context_id; // Save this! // Follow-up - must include context_id const result2 = await mcp.call('get_products', { + idempotency_key: "550e8400-e29b-41d4-a716-446655442077", + buying_mode: "brief", context_id: contextId, // Required for continuity brief: "Focus on premium inventory" }); diff --git a/docs/building/by-layer/L4/build-a-caller.mdx b/docs/building/by-layer/L4/build-a-caller.mdx index 025f4cde5e..8fb37c3103 100644 --- a/docs/building/by-layer/L4/build-a-caller.mdx +++ b/docs/building/by-layer/L4/build-a-caller.mdx @@ -118,6 +118,8 @@ Typed methods on the client correspond to AdCP tools. The SDK validates your req ```typescript const products = await client.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655442046', + buying_mode: 'brief', brief: 'Video campaign for pet owners, 18–34, US, $50K monthly', }); diff --git a/docs/building/by-layer/L4/choose-your-sdk.mdx b/docs/building/by-layer/L4/choose-your-sdk.mdx index 18156a3a7b..4db9be9790 100644 --- a/docs/building/by-layer/L4/choose-your-sdk.mdx +++ b/docs/building/by-layer/L4/choose-your-sdk.mdx @@ -48,6 +48,8 @@ const client = createSingleAgentClient({ }); const products = await client.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655442044', + buying_mode: 'brief', brief: 'Video campaign for pet owners', }); ``` @@ -87,7 +89,11 @@ client = ADCPClient(AgentConfig( )) result = await client.get_products( - GetProductsRequest(brief='Video campaign for pet owners'), + GetProductsRequest( + idempotency_key='550e8400-e29b-41d4-a716-446655442045', + buying_mode='brief', + brief='Video campaign for pet owners', + ), ) ``` @@ -127,9 +133,9 @@ Both SDKs share the same positional shape: `adcp [tool] [payload]`. The ```bash npx @adcp/sdk@latest --help npx @adcp/sdk@latest --save-auth my-agent https://sales.example.com/mcp -npx @adcp/sdk@latest my-agent get_products '{"brief":"CTV campaign"}' +npx @adcp/sdk@latest my-agent get_products '{"idempotency_key":"550e8400-e29b-41d4-a716-446655442062","buying_mode":"brief","brief":"CTV campaign"}' # or against the built-in public test agent: -npx @adcp/sdk@latest test-mcp get_products '{"brief":"CTV campaign"}' +npx @adcp/sdk@latest test-mcp get_products '{"idempotency_key":"550e8400-e29b-41d4-a716-446655442063","buying_mode":"brief","brief":"CTV campaign"}' ``` The CLI also drives storyboards (`adcp storyboard run`), conformance grading (`adcp grade`), and registry diagnostics. See `--help` for the full surface. @@ -139,7 +145,7 @@ The CLI also drives storyboards (`adcp storyboard run`), conformance grading (`a ```bash uvx adcp --help uvx adcp --save-auth my-agent https://sales.example.com/mcp -uvx adcp my-agent get_products '{"brief":"CTV campaign"}' +uvx adcp my-agent get_products '{"idempotency_key":"550e8400-e29b-41d4-a716-446655442064","buying_mode":"brief","brief":"CTV campaign"}' ``` ## What's next diff --git a/docs/building/concepts/how-agents-communicate.mdx b/docs/building/concepts/how-agents-communicate.mdx index ff1ae4732a..00f02e9ac5 100644 --- a/docs/building/concepts/how-agents-communicate.mdx +++ b/docs/building/concepts/how-agents-communicate.mdx @@ -73,6 +73,7 @@ The buyer agent asks "what can I buy?" and gets back a structured catalog of med ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442007", "buying_mode": "brief", "brief": "Premium video inventory on sports content for Q2" } diff --git a/docs/building/concepts/managing-response-size.mdx b/docs/building/concepts/managing-response-size.mdx index ab73f2a432..2e6926c5b6 100644 --- a/docs/building/concepts/managing-response-size.mdx +++ b/docs/building/concepts/managing-response-size.mdx @@ -33,6 +33,7 @@ Use `buying_mode: "brief"` with a tight `pagination.max_results` to get curated ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442001", "buying_mode": "brief", "brief": "Premium video placements for a CPG brand targeting US adults 25-54", "pagination": { "max_results": 5 } @@ -51,6 +52,7 @@ When you only need a subset of product data, pass `fields` to restrict the respo ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442002", "buying_mode": "brief", "brief": "Sports streaming inventory for Q4", "fields": ["product_id", "name", "pricing_options"] diff --git a/docs/building/cross-cutting/version-adaptation.mdx b/docs/building/cross-cutting/version-adaptation.mdx index 5963de2ebc..2028271476 100644 --- a/docs/building/cross-cutting/version-adaptation.mdx +++ b/docs/building/cross-cutting/version-adaptation.mdx @@ -39,7 +39,11 @@ const client = ADCPMultiAgentClient.simple( ); const agent = client.agent('default-agent'); -const result = await agent.getProducts({ brief: 'CTV inventory' }); +const result = await agent.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655442052', + buying_mode: 'brief', + brief: 'CTV inventory', +}); ``` Python and Go SDKs expose the same mechanism under their idiomatic call sites — see each SDK's repo. The shape is consistent: per-agent or per-call version pin, validated at construction time, with adapter modules translating to/from the canonical shape transparently. @@ -136,7 +140,11 @@ There are two places version mismatch can surface on the client, and they fire i import { VersionUnsupportedError } from '@adcp/sdk'; try { - const result = await agent.getProducts({ brief: '…' }); + const result = await agent.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655442053', + buying_mode: 'brief', + brief: '…', + }); } catch (err) { if (err instanceof VersionUnsupportedError) { // peer doesn't support this call at the pinned version — @@ -149,7 +157,11 @@ try { **2. `VERSION_UNSUPPORTED` envelope from the wire.** When the mismatch is only detected on the server side (e.g., the buyer's `adcp_major_version` parses different than the buyer's `adcp_version` string), the response carries a typed `VERSION_UNSUPPORTED` error envelope that echoes the seller's `supported_versions`: ```ts -const result = await agent.getProducts({ brief: '…' }); +const result = await agent.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655442054', + buying_mode: 'brief', + brief: '…', +}); if (!result.success && result.adcpError?.code === 'VERSION_UNSUPPORTED') { const supported = result.adcpError.details?.supported_versions ?? []; diff --git a/docs/building/operating/seller-integration.mdx b/docs/building/operating/seller-integration.mdx index 8126e049e0..6d370e11a2 100644 --- a/docs/building/operating/seller-integration.mdx +++ b/docs/building/operating/seller-integration.mdx @@ -106,6 +106,7 @@ Implement `get_products` to describe what you sell. Each product represents a bu ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442000", "buying_mode": "brief", "brief": "Premium display placements for consumer electronics brand" } diff --git a/docs/contributing/testable-snippets.md b/docs/contributing/testable-snippets.md index a439fe89c7..081c7481c2 100644 --- a/docs/contributing/testable-snippets.md +++ b/docs/contributing/testable-snippets.md @@ -44,6 +44,8 @@ Once a page is marked `testable: true`, all code blocks are executed: import { testAgent } from '@adcp/sdk/testing'; const products = await testAgent.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655442068', + buying_mode: 'brief', brief: 'Premium athletic footwear with innovative cushioning', brand: { domain: 'nike.com' @@ -60,7 +62,7 @@ Use snippet metadata for examples that need local preconditions: ````markdown ```bash requires-env=ADCP_AUTH_TOKEN -uvx adcp https://test-agent.adcontextprotocol.org/sales/mcp get_products '{}' --auth $ADCP_AUTH_TOKEN +uvx adcp https://test-agent.adcontextprotocol.org/sales/mcp get_products '{"idempotency_key":"550e8400-e29b-41d4-a716-446655442069","buying_mode":"brief","brief":"Premium CTV inventory"}' --auth $ADCP_AUTH_TOKEN ``` ```javascript integration=true @@ -80,11 +82,15 @@ import { testAgent, testAgentNoAuth } from '@adcp/sdk/testing'; // Authenticated access const fullCatalog = await testAgent.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655442056', + buying_mode: 'brief', brief: 'Premium CTV inventory' }); // Unauthenticated access const publicCatalog = await testAgentNoAuth.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655442057', + buying_mode: 'brief', brief: 'Premium CTV inventory' }); ``` @@ -97,11 +103,15 @@ from adcp.testing import test_agent, test_agent_no_auth async def example(): # Authenticated access full_catalog = await test_agent.simple.get_products( + idempotency_key='550e8400-e29b-41d4-a716-446655442058', + buying_mode='brief', brief='Premium CTV inventory' ) # Unauthenticated access public_catalog = await test_agent_no_auth.simple.get_products( + idempotency_key='550e8400-e29b-41d4-a716-446655442059', + buying_mode='brief', brief='Premium CTV inventory' ) @@ -138,6 +148,8 @@ const client = new AdcpClient({ }); const products = await client.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655442060', + buying_mode: 'brief', brief: 'Nike Air Max 2024' }); diff --git a/docs/governance/property/authorized-properties.mdx b/docs/governance/property/authorized-properties.mdx index 74fcbc11de..a6257c342c 100644 --- a/docs/governance/property/authorized-properties.mdx +++ b/docs/governance/property/authorized-properties.mdx @@ -183,7 +183,11 @@ for (const domain of publisherDomains) { ### 2. Product Validation ```javascript // When evaluating a product -const result = await salesAgent.call('get_products', {brief: "Chicago radio ads"}); +const result = await salesAgent.call('get_products', { + idempotency_key: "550e8400-e29b-41d4-a716-446655442070", + buying_mode: "brief", + brief: "Chicago radio ads", +}); const product = result.products[0]; // Validate authorization for each publisher in publisher_properties diff --git a/docs/intro.mdx b/docs/intro.mdx index faa64be28d..26990bfbba 100644 --- a/docs/intro.mdx +++ b/docs/intro.mdx @@ -127,6 +127,7 @@ With AdCP, `get_products` sends the same brief to every connected seller. Sam de ```json { "$schema": "https://adcontextprotocol.org/schemas/v3/media-buy/get-products-request.json", + "idempotency_key": "550e8400-e29b-41d4-a716-446655442003", "buying_mode": "brief", "brief": "Premium sports video inventory, Q2 2026, targeting 25-45 males interested in outdoor recreation. Budget $50K across CTV and display.", "brand": { "domain": "acmeoutdoor.com" } @@ -167,6 +168,7 @@ But Sam isn't done. He likes StreamHaus's sports package but wants to shift budg ```json { "$schema": "https://adcontextprotocol.org/schemas/v3/media-buy/get-products-request.json", + "idempotency_key": "550e8400-e29b-41d4-a716-446655442004", "buying_mode": "refine", "refine": [ { diff --git a/docs/learning/foundations/a2b-testing-your-first-agent.mdx b/docs/learning/foundations/a2b-testing-your-first-agent.mdx index dce8b5e59e..f6459685a2 100644 --- a/docs/learning/foundations/a2b-testing-your-first-agent.mdx +++ b/docs/learning/foundations/a2b-testing-your-first-agent.mdx @@ -81,6 +81,7 @@ curl -X POST https://test-agent.adcontextprotocol.org/sales/mcp \ "params": { "name": "get_products", "arguments": { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442055", "adcp_major_version": 3, "account": { "brand": { "domain": "nova-motors.com" }, diff --git a/docs/learning/supplements/buyer-briefs-and-get-products.mdx b/docs/learning/supplements/buyer-briefs-and-get-products.mdx index d5397b42db..1cba1f121e 100644 --- a/docs/learning/supplements/buyer-briefs-and-get-products.mdx +++ b/docs/learning/supplements/buyer-briefs-and-get-products.mdx @@ -35,6 +35,7 @@ Example: ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442008", "buying_mode": "brief", "brief": "Launch Nova Running's spring trail shoe line with outdoor enthusiasts. Favor trusted adventure and fitness contexts, avoid discount-led positioning, and prioritize packages that can support a brand-lift readout.", "brand": { @@ -61,6 +62,7 @@ Example: ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442009", "buying_mode": "brief", "brief": "Launch Nova Running's spring trail shoe line with outdoor enthusiasts. Favor trusted adventure and fitness contexts.", "brand": { diff --git a/docs/learning/tracks/buyer.mdx b/docs/learning/tracks/buyer.mdx index 8efc62c626..7ba298ee00 100644 --- a/docs/learning/tracks/buyer.mdx +++ b/docs/learning/tracks/buyer.mdx @@ -263,7 +263,7 @@ To see the seller responses your agent must handle, you can inspect the test sel ```bash # Inspects the test SELLER's get_products response — the shape your buyer agent must parse. -npx @adcp/sdk@latest test-mcp get_products '{"brief":"your campaign brief"}' +npx @adcp/sdk@latest test-mcp get_products '{"idempotency_key":"550e8400-e29b-41d4-a716-446655442065","buying_mode":"brief","brief":"your campaign brief"}' ``` See [Build a caller](/docs/building/by-layer/L4/build-a-caller) for client setup and [Validate Your Agent](/docs/building/verification/validate-your-agent) for the storyboard workflow. diff --git a/docs/media-buy/advanced-topics/accountability.mdx b/docs/media-buy/advanced-topics/accountability.mdx index c15cdbd0af..cf3fffaeb0 100644 --- a/docs/media-buy/advanced-topics/accountability.mdx +++ b/docs/media-buy/advanced-topics/accountability.mdx @@ -23,6 +23,7 @@ At `get_products`, the buyer filters for products that meet their performance re ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442010", "buying_mode": "brief", "brief": "Premium video for CPG brand, Q3 flight", "filters": { diff --git a/docs/media-buy/advanced-topics/sandbox.mdx b/docs/media-buy/advanced-topics/sandbox.mdx index 872db380c5..e2ca8f45c8 100644 --- a/docs/media-buy/advanced-topics/sandbox.mdx +++ b/docs/media-buy/advanced-topics/sandbox.mdx @@ -125,6 +125,8 @@ This example shows the buyer-declared account path. For account-id namespaces, r ```json // get_products { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442066", + "buying_mode": "brief", "account": { "brand": { "domain": "acme-corp.com" }, "operator": "acme-corp.com", diff --git a/docs/media-buy/conversion-tracking/index.mdx b/docs/media-buy/conversion-tracking/index.mdx index 41c5399c28..3f96e27976 100644 --- a/docs/media-buy/conversion-tracking/index.mdx +++ b/docs/media-buy/conversion-tracking/index.mdx @@ -339,6 +339,7 @@ for (const seller of sellers) { } const products = await seller.getProducts({ + idempotency_key: "550e8400-e29b-41d4-a716-446655442041", account: seller.account, buying_mode: "brief", brief: campaign.brief, diff --git a/docs/media-buy/creatives/index.mdx b/docs/media-buy/creatives/index.mdx index b28a79b694..aeff214fd5 100644 --- a/docs/media-buy/creatives/index.mdx +++ b/docs/media-buy/creatives/index.mdx @@ -45,6 +45,8 @@ Before producing assets, determine both the inventory contract and the creative ```javascript const products = await get_products({ + idempotency_key: "550e8400-e29b-41d4-a716-446655442049", + buying_mode: "brief", brand: { domain: "acmeoutdoor.example" }, brief: "Acme Outdoor product launch campaign" }); diff --git a/docs/media-buy/index.mdx b/docs/media-buy/index.mdx index 6b8e7eea5d..07867c5c79 100644 --- a/docs/media-buy/index.mdx +++ b/docs/media-buy/index.mdx @@ -25,6 +25,7 @@ In AdCP, the brief is natural language inside `get_products`. Sam doesn't need t ```javascript const products = await Promise.all( sellers.map(seller => seller.getProducts({ + idempotency_key: "550e8400-e29b-41d4-a716-446655442037", buying_mode: "brief", brief: "Premium video inventory on sports and outdoor lifestyle publishers. Q2 flight, $50K budget. Adults 25-54, US and Canada.", brand: { domain: "acmeoutdoor.com" }, @@ -68,6 +69,7 @@ Sam wants to narrow down. He switches to `refine` mode, telling each agent exact ```javascript const refined = await seller.getProducts({ + idempotency_key: "550e8400-e29b-41d4-a716-446655442038", buying_mode: "refine", refine: [ { diff --git a/docs/media-buy/product-discovery/brief-expectations.mdx b/docs/media-buy/product-discovery/brief-expectations.mdx index 61ed76ac09..0014c22be8 100644 --- a/docs/media-buy/product-discovery/brief-expectations.mdx +++ b/docs/media-buy/product-discovery/brief-expectations.mdx @@ -38,6 +38,7 @@ The `brief` field describes **what is being promoted** and **campaign requiremen ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442020", "buying_mode": "brief", "brief": "Nike Air Max 2024 - the latest innovation in cushioning technology featuring sustainable materials, targeting runners and fitness enthusiasts" } @@ -53,6 +54,7 @@ When the buyer will apply their own audience targeting and does not want publish ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442021", "brand": { "domain": "acmecorp.com" }, @@ -204,6 +206,7 @@ Publishers should handle briefs at different completeness levels: ### Wholesale Product Feed ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442022", "brand": {"domain": "acmecorp.com"}, "buying_mode": "wholesale", "filters": { @@ -217,6 +220,7 @@ Publishers should handle briefs at different completeness levels: ### Minimal Brief ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442023", "brand": {"domain": "acmecorp.com"}, "buying_mode": "brief", "brief": "Reach business decision makers" @@ -227,6 +231,7 @@ Publishers should handle briefs at different completeness levels: ### Standard Brief ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442024", "brand": {"domain": "acmecorp.com"}, "buying_mode": "brief", "brief": "Acme Corp project management software - cloud-based solution for remote teams. Reach IT decision makers in tech companies with 50-500 employees, $25K budget for Q1, focusing on driving free trial signups" @@ -237,6 +242,7 @@ Publishers should handle briefs at different completeness levels: ### Comprehensive Brief ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442025", "brand": {"domain": "acmecorp.com"}, "buying_mode": "brief", "brief": "Acme Corp project management software - cloud-based solution for remote teams with AI-powered automation. Drive 500 free trial signups from IT decision makers and project managers at tech companies (50-500 employees) in SF Bay Area and NYC. $25K budget for March 1-31, measured by $50 CPA. We have video and display creatives. Avoid competitor content and news sites." @@ -250,6 +256,7 @@ Buyers can signal delivery type preference without excluding inventory. `preferr ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442026", "buying_mode": "brief", "brief": "Exclusive podcast sponsorship for a fintech brand — we want guaranteed placement on business shows", "preferred_delivery_types": ["guaranteed"] @@ -360,6 +367,7 @@ Publishers should implement NLP to extract: ### Wholesale Buying ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442027", "brand": {"domain": "acmecorp.com"}, "buying_mode": "wholesale", "filters": { diff --git a/docs/media-buy/product-discovery/collections-and-installments.mdx b/docs/media-buy/product-discovery/collections-and-installments.mdx index 29d80869fd..4e2e1f1ba2 100644 --- a/docs/media-buy/product-discovery/collections-and-installments.mdx +++ b/docs/media-buy/product-discovery/collections-and-installments.mdx @@ -782,6 +782,7 @@ Buyers discover collections through the standard `get_products` workflow. Natura ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442011", "buying_mode": "brief", "brief": "Podcast sponsorships for technology collections reaching startup founders in April", "filters": { diff --git a/docs/media-buy/product-discovery/example-briefs.mdx b/docs/media-buy/product-discovery/example-briefs.mdx index 53f01bce59..1bbb54fb54 100644 --- a/docs/media-buy/product-discovery/example-briefs.mdx +++ b/docs/media-buy/product-discovery/example-briefs.mdx @@ -14,6 +14,7 @@ These annotated examples demonstrate how natural language briefs work in AdCP, d ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442028", "buying_mode": "brief", "brief": "Mike's Plumbing Services needs to reach homeowners in the Denver, Colorado area who might need plumbing services. We have $8,000 USD to spend from October 15-31, 2024. Looking for display and native formats to drive phone calls." } @@ -42,6 +43,7 @@ Publishers can suggest targeting approaches like: ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442029", "buying_mode": "brief", "brief": "TechGear Pro is launching premium wireless headphones in the United States. Our customers are typically young professionals who commute, work out regularly, and value high-quality audio for both music and calls. They're willing to pay more for products that last longer and perform better. We need video and display formats to drive online sales during our launch November 1-14, 2024. Budget is $25,000 USD with a target of acquiring customers at $45-55 each." } @@ -70,6 +72,7 @@ Publishers can suggest targeting approaches like: ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442030", "buying_mode": "brief", "brief": "CloudSync Solutions helps companies manage data across multiple cloud platforms. Our ideal customers are growing businesses in the United States, Canada, United Kingdom, and Germany that have recently adopted cloud services and are struggling to keep data synchronized. These companies typically have distributed teams, use multiple SaaS tools, and are concerned about data security and compliance. The decision makers are usually technical leaders who report directly to the C-suite and are tasked with modernizing their company's infrastructure. We're looking for native content and display formats to generate qualified leads at $200-250 per lead. Q4 2024 campaign with $90,000 USD total budget." } @@ -98,6 +101,7 @@ Publishers can suggest targeting approaches like: ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442031", "buying_mode": "brief", "brief": "EcoMotion is launching our new hybrid SUV in the United States, specifically California, Pacific Northwest, and Northeast regions. We have three distinct customer groups we want to reach with video, Connected TV, and display formats: @@ -133,6 +137,7 @@ Campaign runs October-December 2024 with $450,000 USD budget. Success means driv ### Financial Services - United States ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442032", "buying_mode": "brief", "brief": "NextGen Banking is promoting our high-yield savings account across the United States. Our target customers are professionals who have accumulated some savings but keep it in traditional banks earning minimal interest. They're financially responsible but not necessarily investment-savvy, and they value security and ease of use over complex features. Looking for display and native formats to acquire 5,000 new accounts in January 2025 with $400,000 USD budget." } @@ -141,6 +146,7 @@ Campaign runs October-December 2024 with $450,000 USD budget. Success means driv ### Healthcare - Regional US ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442033", "buying_mode": "brief", "brief": "HealthFirst Urgent Care serves families in Ohio who need convenient, affordable healthcare. Our patients typically have insurance but want to avoid emergency room costs and wait times. They're parents with young children, working professionals who can't take time off for appointments, and seniors who need accessible care close to home. We need display and video formats to drive appointment bookings. $20,000 USD monthly budget." } @@ -149,6 +155,7 @@ Campaign runs October-December 2024 with $450,000 USD budget. Success means driv ### Streaming Service - North America ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442034", "buying_mode": "brief", "brief": "StreamPlus is expanding in the United States and Canada. Our subscribers love live sports but have cut the cord on traditional cable. They're social viewers who watch games with friends and family, follow multiple teams, and want access to both local and national broadcasts. We need Connected TV, video, and display formats for our Q4 2024 campaign with $2M USD budget to drive free trial sign-ups." } @@ -158,6 +165,7 @@ Campaign runs October-December 2024 with $450,000 USD budget. Success means driv ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442035", "buying_mode": "brief", "brief": "Nova Motors is launching the Volta EV across the top 10 US DMAs. We need primetime :30 spots on major network affiliates (ABC, NBC, CBS, FOX) and late fringe :15 spots for frequency. Adults 25-54, $400K budget, 4-week flight in Q4 2026. We want to guarantee against C7 ratings with VideoAmp as the measurement vendor." } @@ -168,6 +176,7 @@ This brief includes broadcast-specific concepts: DMAs (designated market areas), ### Mobile Gaming - Global English Markets ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442036", "buying_mode": "brief", "brief": "GameStudio is launching our puzzle game in the United States, United Kingdom, Canada, and Australia. Our players are typically adults who play mobile games during commutes, breaks, and before bed. They've played games like Candy Crush or Wordle and enjoy mental challenges that don't require long time commitments. Looking for video and display formats to acquire 50,000 players at $3.50 each in January 2025 with $175,000 USD budget." } @@ -268,4 +277,4 @@ Good briefs leave room for publisher expertise: - [Brief Expectations](/docs/media-buy/product-discovery/brief-expectations) - How publishers process briefs - [Creative Formats](/docs/creative/formats) - Understanding format specifications and discovery - [Media Buy Lifecycle](/docs/media-buy/media-buys) - Campaign execution workflow -- [Product Discovery](/docs/media-buy/product-discovery) - How briefs influence product selection \ No newline at end of file +- [Product Discovery](/docs/media-buy/product-discovery) - How briefs influence product selection diff --git a/docs/media-buy/product-discovery/media-products.mdx b/docs/media-buy/product-discovery/media-products.mdx index 52988908a4..bb4a28b5ab 100644 --- a/docs/media-buy/product-discovery/media-products.mdx +++ b/docs/media-buy/product-discovery/media-products.mdx @@ -1097,6 +1097,7 @@ Proposals can be refined using `buying_mode: "refine"` with the `refine` array. ``` // Initial discovery get_products({ + idempotency_key: "550e8400-e29b-41d4-a716-446655442039", buying_mode: "brief", brief: "Swiss campaign, $50k, 50% desktop/20% mobile/30% in-app, 40% German/60% French" }) @@ -1105,6 +1106,7 @@ get_products({ // Refine the proposal get_products({ + idempotency_key: "550e8400-e29b-41d4-a716-446655442040", buying_mode: "refine", refine: [ { scope: "product", product_id: "ch_desktop_de" }, diff --git a/docs/media-buy/product-discovery/refinement.mdx b/docs/media-buy/product-discovery/refinement.mdx index b6d8bb4dff..464a2ff186 100644 --- a/docs/media-buy/product-discovery/refinement.mdx +++ b/docs/media-buy/product-discovery/refinement.mdx @@ -52,6 +52,7 @@ Product-scoped entries may declare an action. When omitted, the seller treats th ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442013", "buying_mode": "refine", "refine": [ { "scope": "product", "product_id": "prod_video_premium", "ask": "add 16:9 format option" }, @@ -67,6 +68,7 @@ Use `scope: "request"` to describe what you want from the selection as a whole: ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442014", "buying_mode": "refine", "refine": [ { "scope": "request", "ask": "good selection but I want more video options and less display" }, @@ -87,6 +89,7 @@ Reference proposals by `proposal_id` to request adjustments or remove them. Like ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442015", "buying_mode": "refine", "refine": [ { "scope": "product", "product_id": "prod_video_premium" }, @@ -102,6 +105,7 @@ All scopes work together. A single refinement call can set direction for the sel ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442016", "buying_mode": "refine", "refine": [ { "scope": "request", "ask": "increase emphasis on video across the plan" }, @@ -156,6 +160,7 @@ Use `more_like_this` to discover products similar to ones you like. The seller r ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442017", "buying_mode": "refine", "refine": [ { "scope": "product", "product_id": "prod_video_premium", "action": "more_like_this", "ask": "same premium audience but different formats" } @@ -169,6 +174,7 @@ Filters on a refine request represent the complete target state, not a delta. Al ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442018", "buying_mode": "refine", "refine": [ { "scope": "product", "product_id": "prod_video_premium" }, @@ -188,6 +194,7 @@ The product entries define which products the seller should consider for the pro ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442019", "buying_mode": "refine", "refine": [ { "scope": "product", "product_id": "prod_video_premium" }, diff --git a/docs/media-buy/specification.mdx b/docs/media-buy/specification.mdx index 2dc49360ff..a46011e235 100644 --- a/docs/media-buy/specification.mdx +++ b/docs/media-buy/specification.mdx @@ -548,12 +548,13 @@ Sales agents SHOULD target the following response times: ### Idempotency -Sales agents SHOULD support idempotent operations using `idempotency_key`: +Every AdCP task request MUST carry an `idempotency_key`, including `get_products`. Sales agents MUST apply the universal replay contract: -- If an `idempotency_key` has been seen before for the same account, sales agents SHOULD return the existing resource -- This enables safe retries without duplicate creation +- The same key and equivalent canonical payload within the replay window MUST return the original response without re-executing the request. +- The same key with a different canonical payload MUST return `IDEMPOTENCY_CONFLICT`. +- Keys are scoped to the authenticated caller and account so retries cannot cross tenant boundaries. -For mutation tasks (`update_media_buy`, `sync_creatives`), orchestrators MAY include an `idempotency_key` (16-255 characters) for safe retries. If a request fails without a response, resending with the same `idempotency_key` guarantees at-most-once execution. +The 3.1.x migration grace applies only when a seller can guarantee that an omitted-key request will complete as a side-effect-free synchronous read. It never permits a `Submitted` response, task allocation, proposal finalization, or another state transition. `get_products` is polymorphic, so buyers MUST include the key on every call rather than attempting to predict which execution arm the seller will choose. See [Request safety — idempotency](/docs/building/by-layer/L1/security#idempotency) for the normative cache, replay, concurrency, and 3.2 enforcement rules. ### Human-in-the-Loop diff --git a/docs/media-buy/task-reference/get_products.mdx b/docs/media-buy/task-reference/get_products.mdx index 250c0dc0da..a1cabe9f41 100644 --- a/docs/media-buy/task-reference/get_products.mdx +++ b/docs/media-buy/task-reference/get_products.mdx @@ -29,6 +29,7 @@ import { testAgent } from '@adcp/sdk/testing'; import { GetProductsResponseSchema } from '@adcp/sdk'; const result = await testAgent.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655441000', buying_mode: 'brief', brief: 'Premium athletic footwear with innovative cushioning', brand: { @@ -64,6 +65,7 @@ from adcp.testing import test_agent async def discover_products(): result = await test_agent.simple.get_products( + idempotency_key='550e8400-e29b-41d4-a716-446655441001', buying_mode='brief', brief='Premium athletic footwear with innovative cushioning', brand={ @@ -84,7 +86,7 @@ asyncio.run(discover_products()) uvx adcp \ https://test-agent.adcontextprotocol.org/sales/mcp \ get_products \ - '{"buying_mode":"brief","brief":"Premium athletic footwear with innovative cushioning","brand":{"domain":"acmecorp.com"}}' \ + '{"idempotency_key":"550e8400-e29b-41d4-a716-446655441002","buying_mode":"brief","brief":"Premium athletic footwear with innovative cushioning","brand":{"domain":"acmecorp.com"}}' \ --auth $ADCP_AUTH_TOKEN ``` @@ -100,6 +102,7 @@ You can also use structured filters instead of (or in addition to) a brief. In ` import { testAgent } from '@adcp/sdk/testing'; const result = await testAgent.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655441003', buying_mode: 'wholesale', brand: { domain: 'acmecorp.com' @@ -122,6 +125,7 @@ from adcp.testing import test_agent async def discover_with_filters(): result = await test_agent.simple.get_products( + idempotency_key='550e8400-e29b-41d4-a716-446655441004', buying_mode='wholesale', brand={ 'domain': 'acmecorp.com' @@ -143,6 +147,7 @@ asyncio.run(discover_with_filters()) | Parameter | Type | Required | Description | |-----------|------|----------|-------------| +| `idempotency_key` | string | Yes | Unique key for this logical request (16–255 characters; letters, digits, `_`, `.`, `:`, and `-`). Reuse the same key only when retrying the exact initial request, including after a timeout when the original request may have returned `Submitted`. Poll the resulting task through `get_task_status` or MCP task APIs; each new polling request carries its own fresh key. Use a new key whenever any request parameter changes. | | `buying_mode` | string | Yes | `"brief"`, `"wholesale"`, or `"refine"`. `"brief"`: publisher curates products from the brief. `"wholesale"`: raw product feed access for buyer-directed targeting, `brief` must not be provided. `"refine"`: iterate on products and proposals from a previous response using the `refine` array of change requests. v3 clients MUST include `buying_mode`. Sellers receiving requests from pre-v3 clients without `buying_mode` SHOULD default to `"brief"`. **Timing semantics:** `"wholesale"` is a wholesale product feed read — sellers SHOULD return a synchronous response and MUST NOT route a `"wholesale"` request through the async/Submitted arm. Partial completion is signalled via [`incomplete[]`](#incomplete-array), not a task handoff. `"brief"` and `"refine"` MAY complete synchronously OR MAY return a `Submitted` envelope when curation requires upstream-system queries or HITL review the seller cannot complete inside `time_budget`. Buyers needing predictable fast wholesale product feed access MUST use `"wholesale"`. | | `brief` | string | Conditional | Natural language description of campaign requirements. Required when `buying_mode` is `"brief"`. Must not be provided when `buying_mode` is `"wholesale"` or `"refine"`. | | `refine` | [Refine[]](#refine-array) | Conditional | Array of change requests for iterating on products and proposals. Required when `buying_mode` is `"refine"`. Must not be provided when `buying_mode` is `"brief"` or `"wholesale"`. See [Refine array](#refine-array) below. | @@ -263,6 +268,7 @@ Signal-targeting filter example: ```json { "$schema": "/schemas/media-buy/get-products-request.json", + "idempotency_key": "550e8400-e29b-41d4-a716-446655441005", "buying_mode": "wholesale", "filters": { "signal_targeting": [ @@ -303,6 +309,7 @@ Currency-only filter example: ```json { "$schema": "/schemas/media-buy/get-products-request.json", + "idempotency_key": "550e8400-e29b-41d4-a716-446655441006", "buying_mode": "wholesale", "filters": { "pricing_currencies": ["USD"] @@ -560,6 +567,7 @@ Request: ```json { "$schema": "/schemas/media-buy/get-products-request.json", + "idempotency_key": "550e8400-e29b-41d4-a716-446655441007", "buying_mode": "wholesale", "if_wholesale_feed_version": "v2026-05-18T08:00:00Z-acme-rev412" } @@ -664,6 +672,7 @@ Declare a time budget when you need fast results and can accept partial data. Th import { testAgent } from '@adcp/sdk/testing'; const result = await testAgent.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655441008', buying_mode: 'brief', brief: 'CTV and display for brand awareness', brand: { @@ -695,6 +704,7 @@ from adcp.testing import test_agent async def discover_with_time_budget(): result = await test_agent.simple.get_products( + idempotency_key='550e8400-e29b-41d4-a716-446655441009', buying_mode='brief', brief='CTV and display for brand awareness', brand={ @@ -754,6 +764,7 @@ import { testAgent } from '@adcp/sdk/testing'; // wholesale mode: buyer applies their own audiences, no publisher curation const result = await testAgent.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655441010', buying_mode: 'wholesale', brand: { domain: 'acmecorp.com' @@ -775,6 +786,7 @@ from adcp.testing import test_agent async def discover_standard_wholesale_products(): # wholesale mode: buyer applies their own audiences, no publisher curation result = await test_agent.simple.get_products( + idempotency_key='550e8400-e29b-41d4-a716-446655441011', buying_mode='wholesale', brand={ 'domain': 'acmecorp.com' @@ -799,6 +811,7 @@ import { testAgent } from '@adcp/sdk/testing'; // Find products supporting both video and display const result = await testAgent.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655441012', buying_mode: 'brief', brief: 'Brand awareness campaign with video and display', brand: { @@ -821,6 +834,7 @@ from adcp.testing import test_agent async def discover_multi_format(): # Find products supporting both video and display result = await test_agent.simple.get_products( + idempotency_key='550e8400-e29b-41d4-a716-446655441013', buying_mode='brief', brief='Brand awareness campaign with video and display', brand={ @@ -846,6 +860,7 @@ import { testAgent } from '@adcp/sdk/testing'; // Find products within budget and date range for specific countries and channels const result = await testAgent.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655441014', buying_mode: 'brief', brief: 'Q2 campaign for athletic footwear in North America', brand: { @@ -877,6 +892,7 @@ from adcp.testing import test_agent async def discover_with_budget_and_dates(): # Find products within budget and date range for specific countries and channels result = await test_agent.simple.get_products( + idempotency_key='550e8400-e29b-41d4-a716-446655441015', buying_mode='brief', brief='Q2 campaign for athletic footwear in North America', brand={ @@ -911,6 +927,7 @@ import { testAgent } from '@adcp/sdk/testing'; // Get products with property tags const result = await testAgent.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655441016', buying_mode: 'brief', brief: 'Sports content', brand: { @@ -935,6 +952,7 @@ from adcp.testing import test_agent async def discover_property_tags(): # Get products with property tags result = await test_agent.simple.get_products( + idempotency_key='550e8400-e29b-41d4-a716-446655441017', buying_mode='brief', brief='Sports content', brand={ @@ -962,6 +980,7 @@ import { testAgent } from '@adcp/sdk/testing'; // Find guaranteed delivery products for measurement const result = await testAgent.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655441018', buying_mode: 'brief', brief: 'Guaranteed delivery for lift study', brand: { @@ -985,6 +1004,7 @@ from adcp.testing import test_agent async def discover_guaranteed(): # Find guaranteed delivery products for measurement result = await test_agent.simple.get_products( + idempotency_key='550e8400-e29b-41d4-a716-446655441019', buying_mode='brief', brief='Guaranteed delivery for lift study', brand={ @@ -1011,6 +1031,7 @@ import { testAgent } from '@adcp/sdk/testing'; // Find products that only accept IAB standard formats const result = await testAgent.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655441020', buying_mode: 'wholesale', brand: { domain: 'acmecorp.com' @@ -1032,6 +1053,7 @@ from adcp.testing import test_agent async def discover_standard_formats(): # Find products that only accept IAB standard formats result = await test_agent.simple.get_products( + idempotency_key='550e8400-e29b-41d4-a716-446655441021', buying_mode='wholesale', brand={ 'domain': 'acmecorp.com' @@ -1058,6 +1080,7 @@ import { testAgent } from '@adcp/sdk/testing'; // Discover retail media products for specific catalog items const result = await testAgent.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655441022', buying_mode: 'wholesale', brand: { domain: 'acmecorp.com' @@ -1088,6 +1111,7 @@ from adcp.testing import test_agent async def discover_commerce_products(): # Discover retail media products for specific catalog items result = await test_agent.simple.get_products( + idempotency_key='550e8400-e29b-41d4-a716-446655441023', buying_mode='wholesale', brand={ 'domain': 'acmecorp.com' @@ -1113,7 +1137,7 @@ asyncio.run(discover_commerce_products()) uvx adcp \ https://test-agent.adcontextprotocol.org/sales/mcp \ get_products \ - '{"buying_mode":"wholesale","brand":{"domain":"acmecorp.com"},"catalog":{"type":"product","tags":["ketchup","organic"],"category":"food/condiments"},"filters":{"channels":["retail_media"]}}' \ + '{"idempotency_key":"550e8400-e29b-41d4-a716-446655441024","buying_mode":"wholesale","brand":{"domain":"acmecorp.com"},"catalog":{"type":"product","tags":["ketchup","organic"],"category":"food/condiments"},"filters":{"channels":["retail_media"]}}' \ --auth $ADCP_AUTH_TOKEN ``` @@ -1163,6 +1187,7 @@ import { testAgent } from '@adcp/sdk/testing'; // Filter products by property list from governance agent const result = await testAgent.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655441025', buying_mode: 'brief', brief: 'Brand-safe inventory for family brand', brand: { @@ -1192,6 +1217,7 @@ from adcp.testing import test_agent async def discover_with_property_list(): # Filter products by property list from governance agent result = await test_agent.simple.get_products( + idempotency_key='550e8400-e29b-41d4-a716-446655441026', buying_mode='brief', brief='Brand-safe inventory for family brand', brand={ @@ -1241,6 +1267,7 @@ Minimal example: ```json test=false { + "idempotency_key": "550e8400-e29b-41d4-a716-446655441027", "buying_mode": "refine", "refine": [ { "scope": "request", "ask": "more video, less display" }, @@ -1337,6 +1364,7 @@ import { testAgent, testAgentNoAuth } from '@adcp/sdk/testing'; // WITH authentication - full product results with pricing const fullProducts = await testAgent.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655441028', buying_mode: 'brief', brief: 'Premium CTV inventory for brand awareness', brand: { @@ -1353,6 +1381,7 @@ console.log(`First product pricing: ${fullProducts.data.products[0].pricing_opti // WITHOUT authentication - limited public product results const publicProducts = await testAgentNoAuth.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655441029', buying_mode: 'brief', brief: 'Premium CTV inventory for brand awareness', brand: { @@ -1375,6 +1404,7 @@ from adcp.testing import test_agent, test_agent_no_auth async def compare_auth(): # WITH authentication - full product results with pricing full_products = await test_agent.simple.get_products( + idempotency_key='550e8400-e29b-41d4-a716-446655441030', buying_mode='brief', brief='Premium CTV inventory for brand awareness', brand={ @@ -1387,6 +1417,7 @@ async def compare_auth(): # WITHOUT authentication - limited public product results public_products = await test_agent_no_auth.simple.get_products( + idempotency_key='550e8400-e29b-41d4-a716-446655441031', buying_mode='brief', brief='Premium CTV inventory for brand awareness', brand={ @@ -1422,6 +1453,14 @@ Most product searches complete immediately, but some scenarios require asynchron #### SDK Status Handling ```typescript +import { randomUUID } from 'node:crypto'; + +const params = { + idempotency_key: randomUUID(), + buying_mode: 'brief', + brief: 'Premium CTV inventory for brand awareness', +}; + const initial = await agent.getProducts(params); const final = initial.status === 'submitted' @@ -1463,6 +1502,7 @@ POST /api/mcp/call_tool { "name": "get_products", "arguments": { + "idempotency_key": "550e8400-e29b-41d4-a716-446655441032", "buying_mode": "brief", "brief": "CTV inventory for sports audience", "brand": { "domain": "acmecorp.com" } @@ -1522,6 +1562,7 @@ POST /api/mcp/call_tool { "name": "get_products", "arguments": { + "idempotency_key": "550e8400-e29b-41d4-a716-446655441033", "buying_mode": "brief", "brief": "Premium inventory across all formats for luxury automotive brand", "brand": { "domain": "acmecorp.com" }, @@ -1573,6 +1614,7 @@ POST /api/a2a "data": { "skill": "get_products", "parameters": { + "idempotency_key": "550e8400-e29b-41d4-a716-446655441034", "buying_mode": "brief", "brief": "CTV inventory for sports audience", "brand": { "domain": "acmecorp.com" } @@ -1672,6 +1714,7 @@ POST /api/a2a "data": { "skill": "get_products", "parameters": { + "idempotency_key": "550e8400-e29b-41d4-a716-446655441035", "buying_mode": "brief", "brief": "Premium inventory across all formats for luxury automotive brand", "brand": { "domain": "acmecorp.com" } diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index d91218fe1b..00fa6f20c6 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -1101,6 +1101,8 @@ const postalSupport = mediaBuy.execution?.targeting?.geo_postal_areas; // Filter products to sellers with specific geo targeting capabilities const products = await client.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655442047', + buying_mode: 'brief', brief: "Premium video inventory in US for ZIP-targeted campaign", filters: { channels: ['olv', 'ctv'], @@ -1158,6 +1160,8 @@ For locally-bound inventory, products ARE geographically specific. A radio stati ```javascript // Find radio products in specific DMAs const radioProducts = await client.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655442048', + buying_mode: 'brief', brief: "Radio inventory in NYC and LA markets", filters: { channels: ['radio'], diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index e673ec9a86..5d490c818e 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -44,6 +44,8 @@ curl -X POST $AGENT_URL \ "params": { "name": "get_products", "arguments": { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442067", + "buying_mode": "brief", "brief": "Video ads for pet food brand", "brand": { "domain": "premiumpetfoods.com" } } @@ -287,6 +289,8 @@ const client = new ADCPMultiAgentClient([{ }]); const result = await client.agent('test').getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655442043', + buying_mode: 'brief', brief: 'Video ads for pet food brand', brand: { domain: 'premiumpetfoods.com' }, }); diff --git a/docs/reference/implementor-faq.mdx b/docs/reference/implementor-faq.mdx index 83f10c0e91..0f787c12f0 100644 --- a/docs/reference/implementor-faq.mdx +++ b/docs/reference/implementor-faq.mdx @@ -11,13 +11,15 @@ Common questions from teams building AdCP sales agents and integrations, with di ### Q: Does `get_products` require specific parameters like budget, dates, and objectives? -**A:** Currently, `get_products` only has three parameters: `brief` (natural language string), `brand`, and `filters` (structured filters). Campaign details like budget, dates, and objectives should be included in the natural language `brief`. +**A:** Every request requires `idempotency_key` and `buying_mode`. In `buying_mode: "brief"`, the natural-language `brief` carries campaign requirements; `brand` and structured `filters` provide identity and discovery constraints. Campaign details like budget, dates, and objectives should be included in the natural-language brief when no dedicated filter exists. **Future:** Structured parameters for budget, dates, objectives, and targeting are being considered to reduce conversational back-and-forth. Track the roadmap for updates. **Current Workaround:** Include these details in your brief: ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442078", + "buying_mode": "brief", "brand": { "domain": "acmecorp.com" }, @@ -227,6 +229,8 @@ Buyers can discover the agent's portfolio via [`get_adcp_capabilities`](/docs/pr ```javascript // Test blocked category const response = await get_products({ + idempotency_key: "550e8400-e29b-41d4-a716-446655442051", + buying_mode: "brief", brand: { domain: "test-alcohol.example.com" }, diff --git a/docs/reference/release-notes.mdx b/docs/reference/release-notes.mdx index cdc20f02c4..642ef4f36a 100644 --- a/docs/reference/release-notes.mdx +++ b/docs/reference/release-notes.mdx @@ -186,7 +186,7 @@ A bundle of normative clarifications that codify deployed behavior and close amb **`idempotency_key` is required on every AdCP task request — read and mutating alike (#4399b).** The 3.0 contract framed it as mutating-only, but `get_products` is polymorphic (`brief`/`wholesale` may return `Submitted`; `refine`+`finalize` is a commit) and buyers can't classify at call time. Enforcement is **staged**: -- **3.1.0** — sellers MUST accept reads that carry `idempotency_key`; SHOULD reject reads that omit it (MAY accept the omission during the 3.1.x maintenance window). +- **3.1.0** — sellers MUST accept reads that carry `idempotency_key`; SHOULD reject reads that omit it (MAY accept the omission during the 3.1.x maintenance window only for guaranteed side-effect-free synchronous reads). Calls that return `Submitted` or commit/finalize state are never covered by the omission grace. - **3.2.0** — sellers MUST reject reads that omit it. The cache holds read responses too from 3.1 onward — sellers MUST encrypt the cache tier at rest with the same controls applied to the underlying resource store. Operators with read-heavy buyer mixes SHOULD raise the rule-8 insert ceiling (sized originally against write-heavy traffic). See [security.mdx § Idempotency](/docs/building/by-layer/L1/security#idempotency) for the full contract. @@ -212,7 +212,7 @@ The cache holds read responses too from 3.1 onward — sellers MUST encrypt the | If you are… | What you need to do | |---|---| | A buyer using `@adcp/client` or `adcp-py` | Nothing. Both SDKs already send `idempotency_key` uniformly on every call. | -| A buyer with hand-rolled MCP clients (curl, thin clients, raw OpenAPI codegen) | Add `idempotency_key` to read tools (`get_products`, `list_creative_formats`, `list_accounts`, etc.) before the 3.2 cut. 3.1.0 sellers MAY accept omission during the grace window; 3.2.0 sellers MUST reject. | +| A buyer with hand-rolled MCP clients (curl, thin clients, raw OpenAPI codegen) | Add `idempotency_key` to polymorphic tools such as `get_products` now: calls that allocate tasks or commit/finalize state have no omission grace, and buyers cannot predict the execution arm. Add it to guaranteed side-effect-free synchronous reads (`list_creative_formats`, `list_accounts`, etc.) before the 3.2 cut; 3.1.x sellers MAY accept omission only on that narrower read path. | | A seller on FastMCP/Pydantic, Zod `.strict()`, or OpenAPI codegen | Audit input models — they MUST accept envelope fields (`idempotency_key`, `context_id`, `context`, `governance_context`, `push_notification_config`) on every tool. Use `extra='allow'` (Pydantic), drop `.strict()` (Zod), or fix codegen config (OpenAPI). | | A seller emitting `create_media_buy` synchronous-success responses with embedded `status` | Nothing changes. Existing byte-stable replay already conforms; the rule was silent on this case before, now it's explicit. | | A buyer agent reading `status` from a mutation response | Add a `replayed: true` guard: if set, call the resource's read endpoint (`get_media_buys`, `list_accounts`, etc.) before any state-dependent action. Without the guard, you'll hit `NOT_CANCELLABLE` and similar state-machine bugs against replayed responses. | diff --git a/docs/reference/whats-new-in-3-1.mdx b/docs/reference/whats-new-in-3-1.mdx index 3bd82de2d6..9568e05684 100644 --- a/docs/reference/whats-new-in-3-1.mdx +++ b/docs/reference/whats-new-in-3-1.mdx @@ -288,7 +288,7 @@ Normative tightenings landed as the spec settled through prerelease validation. - **`PROPOSAL_NOT_FOUND` error code** (#4043). Completes the proposal-lifecycle error catalog (alongside `PROPOSAL_EXPIRED` and `PROPOSAL_NOT_COMMITTED`). Sellers MUST return it when a referenced `proposal_id` isn't recognized — wrong tenant, evicted from cache, or never finalized. Recovery: correctable. - **Forward-compatible `error.code` decoding** (#4227). Receivers MUST treat `error.code` as an **open enum** — decode unknown codes without rejecting, classify recovery from `error.recovery`, default to `transient` when recovery is absent. Senders from 3.1 onward MUST populate `error.recovery` on every error. Unblocks additive-in-patch for error codes on future maintenance lines without breaking pinned-version receivers. -- **`idempotency_key` required on every AdCP task request** (#4399). Closes a longstanding gap where the spec said dedupe via idempotency_key but didn't require buyers to send one. Sellers MAY reject requests missing the key after 3.1 GA. +- **`idempotency_key` required on every AdCP task request** (#4399). Closes a longstanding gap where the spec said dedupe via idempotency_key but didn't require buyers to send one. In 3.1.x, sellers SHOULD reject omissions and MAY accept one only for a guaranteed side-effect-free synchronous read; requests that allocate a task or change state MUST reject before that effect. The omission grace closes entirely in 3.2. - **MCP tool wrappers MUST tolerate envelope fields** (#4399). The protocol envelope (`status`, `context_id`, `context`, `task_id`, `timestamp`, `replayed`, `adcp_error`, `governance_context`, `idempotency_key`) on MCP requests now goes through the wrapper layer instead of being rejected as "unexpected fields." Closes a wrapper-layer bug where adopters had to omit envelope fields to call MCP successfully. - **MCP serialization normalization** (#2911). Drops `payload.required` from the protocol-envelope schema; adds the `context` field at envelope level; clarifies the flat-sibling MCP wire shape (envelope and body fields at the root, no nested `payload:` key). Adopters who'd implemented the de-facto flat shape are unaffected. - **Idempotency replay returns historical snapshot** (#4371). When a buyer retries a stateful create call (e.g., `create_media_buy`) within the replay window, the seller MUST return the **historical snapshot** of state-tracking fields (`status`, `confirmed_at`, etc.) — not the current state. Otherwise an at-most-once retry mutates the response from underneath the buyer. diff --git a/docs/reference/whats-new-in-v3.mdx b/docs/reference/whats-new-in-v3.mdx index 3336155cd1..a667752d32 100644 --- a/docs/reference/whats-new-in-v3.mdx +++ b/docs/reference/whats-new-in-v3.mdx @@ -656,6 +656,7 @@ Sellers respond with `refinement_applied` — a positionally-matched array where ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442061", "buying_mode": "refine", "refine": [ { "scope": "request", "ask": "more video, less display" }, diff --git a/docs/sponsored-intelligence/overview.mdx b/docs/sponsored-intelligence/overview.mdx index 391245fe1c..176d8471aa 100644 --- a/docs/sponsored-intelligence/overview.mdx +++ b/docs/sponsored-intelligence/overview.mdx @@ -105,6 +105,7 @@ Priya discovers what NovaMind offers — same `get_products` she'd use for CTV o ```javascript const products = await novamind.getProducts({ + idempotency_key: "550e8400-e29b-41d4-a716-446655442042", buying_mode: "brief", brief: "Outdoor gear brand. Want to reach people asking about hiking, camping, and trail running. Budget $15K/month.", brand: { domain: "ridgelinegear.com" }, diff --git a/docs/sponsored-intelligence/workflow.mdx b/docs/sponsored-intelligence/workflow.mdx index 88731af214..fc02218035 100644 --- a/docs/sponsored-intelligence/workflow.mdx +++ b/docs/sponsored-intelligence/workflow.mdx @@ -101,6 +101,7 @@ Query `get_products` with `channels: ["sponsored_intelligence"]` to find Sponsor ```json { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442012", "buying_mode": "brief", "brief": "Promote our new wireless headphones to tech-savvy consumers on AI platforms.", "brand": { diff --git a/scripts/build-compliance.cjs b/scripts/build-compliance.cjs index 8bedf0e2b2..a5a191c340 100644 --- a/scripts/build-compliance.cjs +++ b/scripts/build-compliance.cjs @@ -258,6 +258,10 @@ function lintStoryboardIdempotency(sourceDir, schemasDir) { 'universal/idempotency.yaml', 'universal/webhook-emission.yaml', ]); + const duplicateGeneratedKeyAllowedReplayKeys = new Set([ + 'protocols/media-buy/scenarios/get_products_async.yaml:$context.submitted_products_idempotency_key', + 'protocols/media-buy/scenarios/proposal_finalize.yaml:$context.finalize_idempotency_key', + ]); function isGeneratedIdempotencyKey(value, generatedContextNames) { if (typeof value !== 'string') return false; @@ -330,7 +334,10 @@ function lintStoryboardIdempotency(sourceDir, schemasDir) { task: step.task, key, }); - } else if (!duplicateGeneratedKeyAllowedFiles.has(rel)) { + } else if ( + !duplicateGeneratedKeyAllowedFiles.has(rel) + && !duplicateGeneratedKeyAllowedReplayKeys.has(`${rel}:${key}`) + ) { const existing = generatedKeyUses.get(key); const current = { file: rel, @@ -407,7 +414,7 @@ function lintStoryboardIdempotency(sourceDir, schemasDir) { `Storyboard idempotency_key freshness lint: ${duplicateGeneratedKeyViolations.length} duplicate generated alias use(s).\n\n` + lines.join('\n') + `\n\nUse a unique \`$generate:uuid_v4#...\` alias for each mutating storyboard step. ` + - `Only universal/idempotency.yaml and universal/webhook-emission.yaml may intentionally reuse aliases for replay vectors.` + `Only explicitly allowlisted replay vectors may intentionally reuse generated keys.` ); } diff --git a/scripts/build-schemas.cjs b/scripts/build-schemas.cjs index bfaf6c5b3d..18cb78ff0a 100644 --- a/scripts/build-schemas.cjs +++ b/scripts/build-schemas.cjs @@ -317,7 +317,8 @@ function ensureDir(dir) { // required it, the storyboard lint sees the task as non-mutating and // passes). // -// A request schema is considered non-mutating if: +// A request schema is considered non-mutating if it does not explicitly +// declare `x-mutates-state: true` and: // 1. Its basename matches a read-only verb pattern // (`get-`, `list-`, `check-`, `validate-`, `preview-`, optionally // prefixed by a domain like `si-get-*`), OR @@ -381,6 +382,8 @@ function hasNaturallyIdempotentMarker(schema) { // Used by both lintMutatingRequestsRequireIdempotencyKey and the manifest // generator — single source of truth for "is this a mutating tool?". function classifyRequestMutating(filePath) { + const schema = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if (schema['x-mutates-state'] === true) return true; return !isNonMutatingRequestBasename(path.basename(filePath)); } @@ -398,10 +401,10 @@ function lintMutatingRequestsRequireIdempotencyKey(sourceDir) { continue; } if (!entry.name.endsWith('-request.json')) continue; - if (isNonMutatingRequestBasename(entry.name)) continue; let schema; try { schema = JSON.parse(fs.readFileSync(p, 'utf8')); } catch { continue; } + if (schema['x-mutates-state'] !== true && isNonMutatingRequestBasename(entry.name)) continue; const required = Array.isArray(schema.required) ? schema.required : []; if (required.includes('idempotency_key')) continue; if (hasNaturallyIdempotentMarker(schema)) continue; diff --git a/server/src/addie/mcp/adcp-tools.ts b/server/src/addie/mcp/adcp-tools.ts index 5db3341779..72fc83c03b 100644 --- a/server/src/addie/mcp/adcp-tools.ts +++ b/server/src/addie/mcp/adcp-tools.ts @@ -160,7 +160,11 @@ export function validateAccountRefParam(account: unknown): string | null { export const ADCP_TASK_REGISTRY: Record = { // Media Buy - get_products: { area: 'media-buy', description: 'Discover advertising products from a sales agent using natural language briefs' }, + get_products: { + area: 'media-buy', + description: 'Discover advertising products from a sales agent using natural language briefs', + validate: validateIdempotencyKey, + }, create_media_buy: { area: 'media-buy', description: 'Create an advertising campaign from selected products', @@ -624,7 +628,7 @@ const callAdcpTaskTool: AddieTool = { type: 'object', description: [ 'Task-specific parameters. Quick reference for common tasks:', - '• get_products: { brief, brand: { domain }, buying_mode?: "brief"|"wholesale"|"refine", filters?: { channels, budget_range } }', + '• get_products: { idempotency_key, brief, brand: { domain }, buying_mode?: "brief"|"wholesale"|"refine", filters?: { channels, budget_range } }', '• create_media_buy: { idempotency_key, account: { account_id } OR { brand:{domain}, operator: "operator.example" }, brand: { domain }, packages: [...] OR proposal_id + total_budget, start_time: "asap" | "2024-06-01T00:00:00Z", end_time: "2024-06-30T23:59:59Z" }', '• update_media_buy: { idempotency_key, account: { account_id } OR { brand:{domain}, operator }, media_buy_id, paused?, canceled?, packages?: [{ package_id, budget? }] }', '• sync_creatives: { idempotency_key, creatives: [{ creative_id, format_kind, format_option_ref?, assets }], assignments? }', @@ -802,6 +806,11 @@ export function createAdcpToolHandlers( return `**Error:** ${validationError}`; } + // Keep the caller-supplied key visible and stable through the training + // shortcut, network execution, and any OAuth continuation. Hidden key + // generation would make an ambiguous timeout impossible to retry safely. + const requestParams = params; + // In-process shortcut for training agent (avoids HTTP round-trip and localhost restrictions) try { const parsedUrl = new URL(agentUrl); @@ -816,7 +825,7 @@ export function createAdcpToolHandlers( userId, moduleId: trainingModuleContext?.moduleId ?? memberModuleId, }; - const result = await executeTrainingAgentTool(task, params, ctx); + const result = await executeTrainingAgentTool(task, requestParams, ctx); if (!result.success) { return [ `**Task failed:** \`${task}\`\n`, @@ -883,7 +892,7 @@ export function createAdcpToolHandlers( ); const client = multiClient.agent('target'); - const result = await client.executeTask(task, params, undefined, { debug }); + const result = await client.executeTask(task, requestParams, undefined, { debug }); if (!result.success) { let output = `**Task failed:** \`${task}\`\n\n**Error:**\n\`\`\`json\n${JSON.stringify(result.error, null, 2)}\n\`\`\``; @@ -918,7 +927,7 @@ export function createAdcpToolHandlers( agentUrl, organizationId, agentContextDb, - { pendingTask: task, pendingParams: params }, + { pendingTask: task, pendingParams: requestParams }, ); if (authUrl) { return ( diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index b71976be2c..94128c3ac4 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -1866,13 +1866,14 @@ export const MEMBER_TOOLS: AddieTool[] = [ type: 'object', properties: { agent_url: { type: 'string', description: 'Agent URL to test against' }, + idempotency_key_prefix: { type: 'string', minLength: 16, maxLength: 220, pattern: '^[A-Za-z0-9_.:-]{16,220}$', description: 'Stable prefix for generated per-brief get_products keys; reuse it when retrying this comparison.' }, media_kit_summary: { type: 'string', description: 'Structured description of what the publisher sells (channels, formats, verticals, pricing tiers, audience capabilities)' }, verticals: { type: 'array', items: { type: 'string' }, description: 'Verticals the publisher serves (e.g., automotive, healthcare, tech)' }, channels: { type: 'array', items: { type: 'string' }, description: 'Channels from the media kit (e.g., display, video, podcast, audio, newsletter, dooh, ctv)' }, formats: { type: 'array', items: { type: 'string' }, description: 'Specific format types offered' }, sample_io: { type: 'string', description: 'Text of a sample IO or RFP response for additional comparison' }, }, - required: ['agent_url', 'media_kit_summary'], + required: ['agent_url', 'idempotency_key_prefix', 'media_kit_summary'], }, }, { @@ -1884,6 +1885,7 @@ export const MEMBER_TOOLS: AddieTool[] = [ type: 'object', properties: { agent_url: { type: 'string', description: 'Agent URL to test against' }, + idempotency_key: { type: 'string', minLength: 16, maxLength: 255, pattern: '^[A-Za-z0-9_.:-]{16,255}$', description: 'Stable key for this get_products request; reuse it after an ambiguous timeout.' }, rfp: { type: 'object', description: 'Structured RFP data extracted by Addie from the publisher\'s document', @@ -1909,7 +1911,7 @@ export const MEMBER_TOOLS: AddieTool[] = [ required: ['brief'], }, }, - required: ['agent_url', 'rfp'], + required: ['agent_url', 'idempotency_key', 'rfp'], }, }, { @@ -1921,6 +1923,8 @@ export const MEMBER_TOOLS: AddieTool[] = [ type: 'object', properties: { agent_url: { type: 'string', description: 'Agent URL to test against' }, + idempotency_key: { type: 'string', minLength: 16, maxLength: 255, pattern: '^[A-Za-z0-9_.:-]{16,255}$', description: 'Stable key for the catalog get_products request; reuse it after an ambiguous timeout.' }, + create_media_buy_idempotency_key: { type: 'string', minLength: 16, maxLength: 255, pattern: '^[A-Za-z0-9_.:-]{16,255}$', description: 'Stable key for the optional create_media_buy execution; reuse it after an ambiguous timeout.' }, line_items: { type: 'array', description: 'Line items extracted from the IO or proposal by Addie', @@ -1944,7 +1948,7 @@ export const MEMBER_TOOLS: AddieTool[] = [ currency: { type: 'string', description: 'Currency for all line items (default: USD)' }, execute: { type: 'boolean', description: 'If true, actually call create_media_buy on the agent. If false (default), only construct the JSON.', default: false }, }, - required: ['agent_url', 'line_items'], + required: ['agent_url', 'idempotency_key', 'create_media_buy_idempotency_key', 'line_items'], }, }, // ============================================ @@ -5580,6 +5584,7 @@ export function createMemberToolHandlers( handlers.set('compare_media_kit', async (input) => { const agentUrl = input.agent_url as string; + const idempotencyKeyPrefix = input.idempotency_key_prefix as string; const mediaKitSummary = (input.media_kit_summary as string).slice(0, 5000); const verticals = input.verticals as string[] | undefined; const channels = (input.channels as string[] | undefined)?.slice(0, 20); @@ -5655,9 +5660,10 @@ export function createMemberToolHandlers( has_audience_targeting: boolean; error?: string; } - const briefResults: BriefResult[] = await Promise.all(briefsToRun.map(async (brief): Promise => { + const briefResults: BriefResult[] = await Promise.all(briefsToRun.map(async (brief, briefIndex): Promise => { try { const result = await client.executeTask('get_products', { + idempotency_key: `${idempotencyKeyPrefix}:${briefIndex}`, buying_mode: 'brief', brief: brief.brief, brand: { name: 'Test Brand', url: 'https://example.com' }, @@ -5851,6 +5857,7 @@ export function createMemberToolHandlers( handlers.set('test_rfp_response', async (input) => { const agentUrl = input.agent_url as string; + const idempotencyKey = input.idempotency_key as string; const rfp = input.rfp as Record; const brief = ((rfp.brief as string) || '').slice(0, 5000); const advertiser = rfp.advertiser as string | undefined; @@ -5883,6 +5890,7 @@ export function createMemberToolHandlers( const result = await Promise.race([ client.executeTask('get_products', { + idempotency_key: idempotencyKey, buying_mode: 'brief', brief, brand: { name: advertiser || 'Test Brand', url: 'https://example.com' }, @@ -6093,6 +6101,8 @@ export function createMemberToolHandlers( handlers.set('test_io_execution', async (input) => { const agentUrl = input.agent_url as string; + const idempotencyKey = input.idempotency_key as string; + const createMediaBuyIdempotencyKey = input.create_media_buy_idempotency_key as string; const lineItems = ((input.line_items as Array>) || []).slice(0, 20); const advertiser = input.advertiser as string | undefined; const currency = (input.currency as string) || 'USD'; @@ -6120,6 +6130,7 @@ export function createMemberToolHandlers( // Get full catalog via wholesale mode const result = await Promise.race([ client.executeTask('get_products', { + idempotency_key: idempotencyKey, buying_mode: 'wholesale', brand: { name: advertiser || 'Test Brand', url: 'https://example.com' }, }), @@ -6336,7 +6347,7 @@ export function createMemberToolHandlers( const latestEnd = allEndDates.length > 0 ? allEndDates.sort().reverse()[0] : new Date(Date.now() + 30 * 86400000).toISOString(); const proposedRequest = mappedPackages.length > 0 ? { - idempotency_key: randomUUID(), + idempotency_key: createMediaBuyIdempotencyKey, brand: { name: advertiser || 'Test Brand', url: 'https://example.com' }, account: { account_id: advertiser || 'test-account' }, start_time: earliestStart, diff --git a/server/src/http.ts b/server/src/http.ts index 2e4b5f96a5..7e32b06e40 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -9394,7 +9394,10 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}< stats.product_count = 0; stats.publisher_count = 0; try { - const result = await client.getProducts({ buying_mode: 'wholesale' }); + const result = await client.getProducts({ + idempotency_key: crypto.randomUUID(), + buying_mode: 'wholesale', + }); if (result.data?.products) { stats.product_count = result.data.products.length; } diff --git a/server/src/mcp-tools.ts b/server/src/mcp-tools.ts index 3ecb20ef07..ae9389719b 100644 --- a/server/src/mcp-tools.ts +++ b/server/src/mcp-tools.ts @@ -197,10 +197,19 @@ export const TOOL_DEFINITIONS = [ }, params: { type: "object", - description: "Parameters to pass to get_products (leave empty for public products)", + description: "Parameters to pass to get_products. Preserve idempotency_key when retrying an ambiguous failure.", + properties: { + idempotency_key: { + type: "string", + minLength: 16, + maxLength: 255, + pattern: "^[A-Za-z0-9_.:-]{16,255}$", + }, + }, + required: ["idempotency_key"], }, }, - required: ["agent_url"], + required: ["agent_url", "params"], }, }, { diff --git a/server/src/routes/registry-api.ts b/server/src/routes/registry-api.ts index 830aff5f20..8e3f5e917f 100644 --- a/server/src/routes/registry-api.ts +++ b/server/src/routes/registry-api.ts @@ -9679,7 +9679,10 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R stats.product_count = 0; stats.publisher_count = 0; try { - const result = await client.getProducts({ buying_mode: 'wholesale' }); + const result = await client.getProducts({ + idempotency_key: randomUUID(), + buying_mode: 'wholesale', + }); if (result.data?.products) { stats.product_count = result.data.products.length; } @@ -9938,7 +9941,10 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R }, publicAgentTransportOptions()); const result = await client.getProducts( - { buying_mode: 'wholesale' }, + { + idempotency_key: randomUUID(), + buying_mode: 'wholesale', + }, undefined, { timeout: PUBLIC_AGENT_TIMEOUT_MS }, ); diff --git a/server/src/training-agent/idempotency.ts b/server/src/training-agent/idempotency.ts index 95f2823954..7637ad8438 100644 --- a/server/src/training-agent/idempotency.ts +++ b/server/src/training-agent/idempotency.ts @@ -56,6 +56,7 @@ export const MUTATING_TOOLS: ReadonlySet = new Set([ 'creative_approval', 'delete_collection_list', 'delete_property_list', + 'get_products', 'log_event', 'provide_performance_feedback', 'report_plan_outcome', diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 3d0a66c6f2..67833a89aa 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -46,7 +46,7 @@ import type { BuildCreativeResponse, CreativeManifest as AdcpCreativeManifest, } from '@adcp/sdk'; -import { CreativeManifestSchema } from '@adcp/sdk/schemas'; +import { CreativeManifestSchema, GetProductsRequestSchema } from '@adcp/sdk/schemas'; import { verifyGovernedServiceAuthorization } from './governance-verify.js'; import { getCanonicalBase } from './canonical-base.js'; /** Escape HTML special characters to prevent injection in generated HTML responses. */ @@ -1418,6 +1418,7 @@ import { validateKeyFormat, scopedPrincipal, getIdempotencyStore, + REPLAY_TTL_SECONDS, } from './idempotency.js'; import { maybeEmitCompletionWebhook } from './webhooks.js'; import { selectSigningCapability } from './request-signing.js'; @@ -1695,6 +1696,16 @@ function installTaskProtocolVersionNegotiation(server: Server): void { * are sandboxed. Production servers should scope tasks by sessionId. */ let sdkTaskStore: InMemoryTaskStore | PostgresTaskStore | null = null; +const inMemoryTaskIdsByNaturalKey = new Map(); + +function idempotentTaskNaturalKey( + principal: string, + toolName: string, + idempotencyKey: string, + payloadHash: string, +): string { + return [principal, toolName, `success:${idempotencyKey}:${payloadHash}`].join('\0'); +} function getTaskStore(): InMemoryTaskStore | PostgresTaskStore { if (!sdkTaskStore) { @@ -1705,6 +1716,54 @@ function getTaskStore(): InMemoryTaskStore | PostgresTaskStore { return sdkTaskStore; } +async function createOrReuseIdempotentTask( + taskStore: InMemoryTaskStore | PostgresTaskStore, + naturalKey: string, + ttl: number, + request: { method: string; params?: { _meta?: Record } }, +) { + const deterministicTaskId = createHash('sha256').update(naturalKey).digest('hex'); + if (taskStore instanceof PostgresTaskStore) { + const existing = await taskStore.getTask(deterministicTaskId); + if (existing) return existing; + try { + return await taskStore.createTask({ ttl, taskId: deterministicTaskId }, 0, request); + } catch (error) { + // A sibling process may have inserted the same naturally keyed task + // between getTask() and createTask(). Re-read instead of allocating a + // second task or surfacing a false failure. + const raced = await taskStore.getTask(deterministicTaskId); + if (raced) return raced; + throw error; + } + } + + const priorId = inMemoryTaskIdsByNaturalKey.get(naturalKey); + if (priorId) { + const existing = await taskStore.getTask(priorId); + if (existing) return existing; + inMemoryTaskIdsByNaturalKey.delete(naturalKey); + } + const created = await taskStore.createTask({ ttl }, 0, request); + inMemoryTaskIdsByNaturalKey.set(naturalKey, created.taskId); + return created; +} + +async function getIdempotentTask( + taskStore: InMemoryTaskStore | PostgresTaskStore, + naturalKey: string, +) { + if (taskStore instanceof PostgresTaskStore) { + const deterministicTaskId = createHash('sha256').update(naturalKey).digest('hex'); + return taskStore.getTask(deterministicTaskId); + } + const taskId = inMemoryTaskIdsByNaturalKey.get(naturalKey); + if (!taskId) return null; + const task = await taskStore.getTask(taskId); + if (!task) inMemoryTaskIdsByNaturalKey.delete(naturalKey); + return task; +} + /** Look up which tools allow task augmentation. */ function toolSupportsTask(toolName: string): boolean { const tool = TOOLS.find(t => t.name === toolName); @@ -1752,6 +1811,7 @@ function withUsageAccountScope>(req: T): T { export function clearTaskStore(): void { sdkTaskStore?.cleanup(); sdkTaskStore = null; + inMemoryTaskIdsByNaturalKey.clear(); } /** Translate the agent's internal governance check shape into the wire-format @@ -3255,11 +3315,19 @@ const TOOLS = [ { name: 'get_products', description: 'Discover available advertising products. Supports brief (curated discovery), wholesale (raw catalog), and refine (iterate on previous results) buying modes. Use this before create_media_buy to find valid product_id and pricing_option_id values. Not for checking delivery or managing existing buys. Returns sandbox catalog data.', - annotations: { readOnlyHint: true, idempotentHint: true }, + // Polymorphic: brief/wholesale can be reads, but Submitted responses + // allocate a task and refine+finalize commits an inventory hold. + annotations: { readOnlyHint: false, idempotentHint: true }, execution: { taskSupport: 'optional' as const }, inputSchema: { type: 'object' as const, properties: { + idempotency_key: { + type: 'string', + minLength: 16, + maxLength: 255, + pattern: '^[A-Za-z0-9_.:-]{16,255}$', + }, buying_mode: { type: 'string', enum: ['brief', 'wholesale', 'refine'] }, brief: { type: 'string' }, refine: { type: 'array' }, @@ -3277,7 +3345,7 @@ const TOOLS = [ }, }, }, - required: ['buying_mode'], + required: ['idempotency_key', 'buying_mode'], }, }, { @@ -3730,7 +3798,70 @@ function toolAvailableForServedAdcpVersion(toolName: string, servedAdcpVersion: // ── Task handler implementations ────────────────────────────────── +function finalizedProposalIds(args: ToolArgs): string[] { + const refine = (args as unknown as { refine?: unknown }).refine; + if (!Array.isArray(refine)) return []; + return [...new Set(refine + .filter((entry): entry is { scope: 'proposal'; proposal_id: string; action: 'finalize' } => ( + typeof entry === 'object' + && entry !== null + && (entry as { scope?: unknown }).scope === 'proposal' + && (entry as { action?: unknown }).action === 'finalize' + && typeof (entry as { proposal_id?: unknown }).proposal_id === 'string' + )) + .map(entry => entry.proposal_id))] + .sort(); +} + export async function handleGetProducts(args: ToolArgs, ctx: TrainingContext): Promise { + const proposalIds = finalizedProposalIds(args); + if (proposalIds.length === 0) return handleGetProductsUnlocked(args, ctx); + + // Serialize proposal commits independently of the buyer's request key. + // Distinct idempotency keys must not be able to create competing holds for + // the same proposal. The normal idempotency store gives us a distributed, + // expiring put-if-absent claim across Fly machines; the claim is always + // released after the commit so a later retry can observe the committed + // proposal and return its original IO/expiry. + const sessionScope = sessionKeyFromArgs(args, ctx.mode, ctx.userId, ctx.moduleId); + const principal = `proposal-finalize-lock:${createHash('sha256').update(sessionScope).digest('hex')}`; + const store = getIdempotencyStore(); + const acquiredKeys: string[] = []; + for (const proposalId of proposalIds) { + const key = `proposal-finalize:${createHash('sha256').update(proposalId).digest('hex')}`; + const claim = await store.check({ principal, key, payload: { proposal_id: proposalId } }); + if (claim.kind !== 'miss') { + await Promise.all(acquiredKeys.map(acquiredKey => store.release({ principal, key: acquiredKey }))); + return { + errors: [{ + code: 'RATE_LIMITED', + message: 'A proposal finalization is already in progress. Retry after a short delay.', + recovery: 'transient', + }], + }; + } + acquiredKeys.push(key); + } + try { + const result = await handleGetProductsUnlocked(args, ctx); + const hasErrors = Array.isArray((result as { errors?: unknown }).errors) + && (result as { errors: unknown[] }).errors.length > 0; + const hasSuccessPayload = Array.isArray((result as { products?: unknown }).products) + || Array.isArray((result as { proposals?: unknown }).proposals); + if (!hasErrors || hasSuccessPayload) { + // Keep the proposal-level claim until the committed proposal is durable. + // Releasing immediately after the in-memory mutation would let a second + // process acquire the lock, reload the still-draft row, and mint a + // competing IO before the outer dispatcher reached its flush step. + await flushDirtySessions(); + } + return result; + } finally { + await Promise.all(acquiredKeys.map(key => store.release({ principal, key }))); + } +} + +async function handleGetProductsUnlocked(args: ToolArgs, ctx: TrainingContext): Promise { const req = args as unknown as GetProductsRequest & ToolArgs; const buyingMode = req.buying_mode || 'brief'; const brief = (req as Record).brief; @@ -9536,6 +9667,50 @@ const HANDLER_MAP: Record = { comply_test_controller: handleComplyTestController, }; +function validateIdempotencyProtectedInput( + toolName: string, + args: Record, +): { message: string; field?: string } | undefined { + if (toolName !== 'get_products') return undefined; + if (args.brief !== undefined && typeof args.brief !== 'string') { + return { message: 'brief must be a string when provided', field: 'brief' }; + } + const parsed = GetProductsRequestSchema.safeParse(args); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const field = issue?.path.map(segment => String(segment)).join('.'); + return { + message: `Invalid get_products request${field ? ` at ${field}` : ''}: ${issue?.message ?? 'schema validation failed'}`, + ...(field && { field }), + }; + } + + // Finalization is a commit boundary, not another refinement. Reject mixed + // arrays before the idempotency store is consulted so an invalid request + // cannot reserve a key or partially mutate an earlier proposal entry. + if (Array.isArray(args.refine)) { + const hasFinalize = args.refine.some(entry => ( + isRecord(entry) + && entry.scope === 'proposal' + && entry.action === 'finalize' + )); + if (hasFinalize) { + const mixedIndex = args.refine.findIndex(entry => !( + isRecord(entry) + && entry.scope === 'proposal' + && entry.action === 'finalize' + )); + if (mixedIndex >= 0) { + return { + message: `Invalid get_products request at refine[${mixedIndex}]: proposal finalization cannot be mixed with request, product, include, or omit refinements. Send finalization as a separate request.`, + field: `refine[${mixedIndex}]`, + }; + } + } + } + return undefined; +} + /** * Execute a training agent tool in-process (no HTTP round-trip). * Used by Addie's adcp-tools during certification demos. @@ -9545,7 +9720,21 @@ export async function executeTrainingAgentTool( args: ToolArgs, ctx: TrainingContext, ): Promise<{ success: boolean; data?: object; error?: string }> { - const versionResolution = resolveServedAdcpVersionForTool(toolName, args as unknown as Record); + return runWithSessionContext(() => executeTrainingAgentToolInContext(toolName, args, ctx)); +} + +async function executeTrainingAgentToolInContext( + toolName: string, + args: ToolArgs, + ctx: TrainingContext, +): Promise<{ success: boolean; data?: object; error?: string }> { + // Context is an envelope field: it is excluded from request equivalence, + // never passed into domain handlers, and echoed from the current attempt. + // Keeping it out of the cached inner response prevents a replay from + // returning the original caller's correlation data. + const rawArgs = args as unknown as Record; + const { context: callerContext, ...handlerArgs } = rawArgs; + const versionResolution = resolveServedAdcpVersionForTool(toolName, handlerArgs); if (!versionResolution.ok) { return { success: false, error: versionResolution.message }; } @@ -9559,10 +9748,76 @@ export async function executeTrainingAgentTool( if (!handler) { return { success: false, error: `Unknown tool: ${toolName}` }; } + const authPrincipal = ctx.principal ?? ctx.userId ?? 'anonymous'; + const accountScope = deriveAccountScope(handlerArgs); + const principal = scopedPrincipal(authPrincipal, accountScope); + const idempotencyKey = handlerArgs.idempotency_key; + let claim: { payloadHash: string } | undefined; + + if (isMutatingTool(toolName)) { + if (idempotencyKey === undefined || idempotencyKey === null) { + return { success: false, error: `idempotency_key is required for ${toolName}` }; + } + if (!validateKeyFormat(idempotencyKey)) { + return { success: false, error: 'idempotency_key has an invalid format' }; + } + const validationError = validateIdempotencyProtectedInput(toolName, handlerArgs); + if (validationError) { + return { success: false, error: validationError.message }; + } + const outcome = await getIdempotencyStore().check({ + principal, + key: idempotencyKey, + payload: handlerArgs, + }); + if (outcome.kind === 'replay') { + return { + success: true, + data: { + ...(outcome.response as object), + replayed: true, + ...(callerContext !== undefined && { context: callerContext }), + }, + }; + } + if (outcome.kind === 'expired') { + return { success: false, error: 'IDEMPOTENCY_EXPIRED' }; + } + if (outcome.kind === 'conflict') { + return { success: false, error: 'IDEMPOTENCY_CONFLICT' }; + } + if (outcome.kind === 'in-flight') { + return { success: false, error: 'RATE_LIMITED: matching request is already in progress' }; + } + claim = { payloadHash: outcome.payloadHash }; + } try { - const result = await Promise.resolve(handler(args, { ...ctx, servedAdcpVersion: versionResolution.servedVersion })); - return { success: true, data: addServedAdcpVersion(result, versionResolution.servedVersion) as object }; + const result = await Promise.resolve(handler( + handlerArgs as ToolArgs, + { ...ctx, servedAdcpVersion: versionResolution.servedVersion }, + )); + const cacheResponse = addServedAdcpVersion(result, versionResolution.servedVersion) as Record; + const response = addServedAdcpVersion(cacheResponse, versionResolution.servedVersion, callerContext) as Record; + if (claim && typeof idempotencyKey === 'string') { + const hasErrors = Array.isArray(cacheResponse.errors) && cacheResponse.errors.length > 0; + const hasAdvisorySuccessPayload = permitsAdvisoryErrors(toolName, cacheResponse); + if (hasErrors && !hasAdvisorySuccessPayload) { + await getIdempotencyStore().release({ principal, key: idempotencyKey }); + } else { + await flushDirtySessions(); + await getIdempotencyStore().save({ + principal, + key: idempotencyKey, + payloadHash: claim.payloadHash, + response: cacheResponse, + }); + } + } + return { success: true, data: response }; } catch (error) { + if (claim && typeof idempotencyKey === 'string') { + await getIdempotencyStore().release({ principal, key: idempotencyKey }); + } logger.error({ error, tool: toolName }, 'Training agent in-process tool error'); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } @@ -9677,6 +9932,8 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { let skipHandler = false; let idempotencyPayloadHash: string | undefined; let idempotencyClaimed = false; + let idempotencyReplayed = false; + let idempotencyReplayResponse: Record | undefined; if (isMutatingTool(name)) { if (idempotencyKey === undefined || idempotencyKey === null) { @@ -9699,6 +9956,17 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { flushable: true, }; } + const validationError = validateIdempotencyProtectedInput(name, handlerArgs); + if (validationError) { + return { + result: adcpError('INVALID_REQUEST', { + message: validationError.message, + ...(validationError.field && { field: validationError.field }), + recovery: 'correctable', + }, callerContext, servedAdcpVersion), + flushable: true, + }; + } const store = getIdempotencyStore(); const outcome = await store.check({ principal: idempotencyPrincipal, @@ -9750,7 +10018,7 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { // without status). Per #4878, every per-task response schema now // requires envelope `status`. const body: Record = { ...(outcome.response as Record), replayed: true }; - if (body.status === undefined) body.status = 'completed'; + if (!isTaskRequest && body.status === undefined) body.status = 'completed'; body.adcp_version = servedAdcpVersion; if (callerContext !== undefined) body.context = callerContext; toolResult = { @@ -9758,12 +10026,74 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { structuredContent: body, }; skipHandler = true; + idempotencyReplayed = true; + idempotencyReplayResponse = body; } else { // 'miss' → the store reserved the claim via putIfAbsent. We must // call save() on success or release() on any other path so the // placeholder doesn't leak. idempotencyPayloadHash = outcome.payloadHash; idempotencyClaimed = true; + + // A previous task execution may have durably stored both its domain + // state and terminal task result, then failed while publishing the + // idempotency-cache entry. Recover that successful task before the + // domain handler runs: looking it up afterward can hide a duplicated + // media buy (or other side effect) behind the original task envelope. + if (isTaskRequest) { + const naturalKey = idempotentTaskNaturalKey( + idempotencyPrincipal, + name, + idempotencyKey, + idempotencyPayloadHash, + ); + try { + const recoveredTask = await getIdempotentTask(taskStore, naturalKey); + if (recoveredTask) { + if (recoveredTask.status !== 'completed') { + throw new Error(`Prior idempotent task ${recoveredTask.taskId} is not recoverable in status ${recoveredTask.status}`); + } + const recoveredResult = await taskStore.getTaskResult(recoveredTask.taskId) as CallToolResult; + const recoveredBody = isRecord(recoveredResult.structuredContent) + ? recoveredResult.structuredContent + : undefined; + if (recoveredResult.isError || !recoveredBody) { + throw new Error(`Prior idempotent task ${recoveredTask.taskId} has no successful structured result`); + } + + const taskResponse = { task: recoveredTask, adcp_version: servedAdcpVersion }; + await store.save({ + principal: idempotencyPrincipal, + key: idempotencyKey, + payloadHash: idempotencyPayloadHash, + response: taskResponse, + }); + const { + context: _cachedContext, + replayed: _cachedReplayMarker, + ...notificationResponse + } = recoveredBody; + maybeEmitCompletionWebhook({ + toolName: name, + args: handlerArgs, + response: notificationResponse, + requestIdempotencyKey: idempotencyKey, + principal: idempotencyPrincipal, + }); + return { + result: { + ...taskResponse, + replayed: true, + ...(callerContext !== undefined && { context: callerContext }), + }, + flushable: false, + }; + } + } catch (error) { + await store.release({ principal: idempotencyPrincipal, key: idempotencyKey }); + throw error; + } + } } } @@ -9862,38 +10192,39 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { throw new Error('Internal error: toolResult missing after dispatch'); } - // Resolve the in-flight claim from check(). Cache only successful inner - // responses (security.mdx rule 2+3); errors, structured error-only - // bodies, and exceptions all release the claim so a retry re-executes. - if (idempotencyClaimed && typeof idempotencyKey === 'string') { + // Resolve an in-flight idempotency claim only after the complete outward + // response is known. Task-augmented requests must cache their task + // envelope, not the handler's inner body, or a replay would allocate a + // second task. Successful state is flushed before the immutable replay is + // published so the cache cannot claim a finalize committed when its + // session mutation was not durable. + const resolveIdempotencyClaim = async ( + responseToCache: Record | null, + ): Promise => { + if (!idempotencyClaimed || typeof idempotencyKey !== 'string') return false; const store = getIdempotencyStore(); - const shouldSave = - cachableResponse !== null - && !toolResult.isError - && !handlerThrew; - if (shouldSave && idempotencyPayloadHash) { + const shouldSave = responseToCache !== null && !toolResult!.isError && !handlerThrew; + if (!shouldSave || !idempotencyPayloadHash) { + await store.release({ principal: idempotencyPrincipal, key: idempotencyKey }); + return false; + } + try { + await flushDirtySessions(); await store.save({ principal: idempotencyPrincipal, key: idempotencyKey, payloadHash: idempotencyPayloadHash, - response: cachableResponse, - }); - } else { - await store.release({ - principal: idempotencyPrincipal, - key: idempotencyKey, + response: responseToCache, }); + return true; + } catch (error) { + await store.release({ principal: idempotencyPrincipal, key: idempotencyKey }); + throw error; } - } + }; - // Fire completion webhook if the buyer supplied a push URL and the tool - // mapped to a TaskType. Emission is fire-and-forget so the sync response - // doesn't wait on the receiver; retries/backoff live inside the emitter. - if ( - cachableResponse !== null - && !toolResult.isError - && !handlerThrew - ) { + const emitCompletionWebhook = (): void => { + if (cachableResponse === null || toolResult!.isError || handlerThrew) return; maybeEmitCompletionWebhook({ toolName: name, args: handlerArgs, @@ -9901,22 +10232,38 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { requestIdempotencyKey: typeof idempotencyKey === 'string' ? idempotencyKey : undefined, principal: idempotencyPrincipal, }); - } + }; // If not task-augmented, return result directly. // flushable=!handlerThrew: if the handler threw, discard in-progress session // state. Structured { errors: [...] } responses still flush — they are // well-formed outcomes that legitimately mutate state. if (!isTaskRequest) { - return { result: toolResult, flushable: !handlerThrew }; - } - - // Training agent tasks resolve immediately, so moderate TTLs suffice. - // 15 minutes gives developers time to inspect tasks while debugging. - // With the rate limiter (300 req/min) this caps live tasks at ~4,500. + const flushed = await resolveIdempotencyClaim(cachableResponse); + // Success notifications must never outrun durable session state or the + // replay record. resolveIdempotencyClaim flushes then saves for every + // mutation-capable task; only after both succeed may delivery begin. + emitCompletionWebhook(); + return { result: toolResult, flushable: !handlerThrew && !flushed }; + } + + // Exact task replay returns the originally cached task envelope and never + // reaches createTask(). This remains true after the task becomes terminal. + if (idempotencyReplayed) { + return { result: idempotencyReplayResponse ?? toolResult, flushable: false }; + } + + // Ordinary/error tasks honor the requested TTL up to the training-agent + // cap. A successful idempotency-protected task is different: its terminal + // record is the crash-recovery receipt if cache publication fails. MCP + // explicitly permits the server to override the requested TTL, so retain + // that receipt for the complete replay window plus the same one-minute + // clock-skew allowance used by the idempotency contract. The returned Task + // reports this actual TTL; callers must not assume their suggestion won. const MAX_TASK_TTL = 15 * 60 * 1000; // 15 minutes const DEFAULT_TASK_TTL = 15 * 60 * 1000; // 15 minutes const clampedTtl = Math.min(taskField?.ttl ?? DEFAULT_TASK_TTL, MAX_TASK_TTL); + const IDEMPOTENT_TASK_RECEIPT_TTL = (REPLAY_TTL_SECONDS + 60) * 1000; // Task-augmented: use the raw module-level task store directly. // The SDK's extra.taskStore wrapper sends notifications/tasks/status @@ -9924,15 +10271,39 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { // request uses a fresh transport). Using the raw store avoids this // while keeping tasks visible to subsequent tasks/get requests. const terminalStatus: 'completed' | 'failed' = taskFailed ? 'failed' : 'completed'; - const created = await taskStore.createTask( - { ttl: clampedTtl }, - 0, - request as unknown as { method: string; params?: { _meta?: Record } }, - ); - await taskStore.storeTaskResult(created.taskId, terminalStatus, toolResult); - const task = await taskStore.getTask(created.taskId); - if (!task) { - throw new Error(`Task disappeared after creation for tool "${name}"`); + let task: Awaited>; + try { + // Commit handler state before exposing a task that contains the result. + // If task/cache persistence fails afterward, a retry observes the + // committed proposal and converges on the same IO/expiry. + await flushDirtySessions(); + const canReuseNaturalTask = ( + idempotencyClaimed + && typeof idempotencyKey === 'string' + && typeof idempotencyPayloadHash === 'string' + && cachableResponse !== null + && !toolResult.isError + && !handlerThrew + ); + const taskRequest = request as unknown as { method: string; params?: { _meta?: Record } }; + const created = canReuseNaturalTask + ? await createOrReuseIdempotentTask( + taskStore, + idempotentTaskNaturalKey(idempotencyPrincipal, name, idempotencyKey!, idempotencyPayloadHash!), + IDEMPOTENT_TASK_RECEIPT_TTL, + taskRequest, + ) + : await taskStore.createTask({ ttl: clampedTtl }, 0, taskRequest); + if (!['completed', 'failed', 'cancelled'].includes(created.status)) { + await taskStore.storeTaskResult(created.taskId, terminalStatus, toolResult); + } + task = await taskStore.getTask(created.taskId); + if (!task) { + throw new Error(`Task disappeared after creation for tool "${name}"`); + } + } catch (error) { + await resolveIdempotencyClaim(null); + throw error; } const errorCode = toolResult.isError ? (toolResult.structuredContent as { adcp_error?: { code?: string } } | undefined)?.adcp_error?.code @@ -9942,7 +10313,10 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { 'Created MCP task', ); - return { result: { task, adcp_version: servedAdcpVersion } as object, flushable: !handlerThrew }; + const taskResponse = { task, adcp_version: servedAdcpVersion } as Record; + const flushed = await resolveIdempotencyClaim(cachableResponse === null ? null : taskResponse); + emitCompletionWebhook(); + return { result: taskResponse, flushable: !handlerThrew && !flushed }; } // tasks/get, tasks/result, tasks/list, tasks/cancel are auto-registered diff --git a/server/src/training-agent/tenants/router.ts b/server/src/training-agent/tenants/router.ts index a458392628..6c45d15b22 100644 --- a/server/src/training-agent/tenants/router.ts +++ b/server/src/training-agent/tenants/router.ts @@ -176,7 +176,7 @@ function tenantMcpHandler(holder: RegistryHolder, tenantId: string, storyboardCo return async (req: Request, res: Response): Promise => { setCORSHeaders(res); - wrapTenantToolDiscoveryProjection(req, res, storyboardCompat); + wrapTenantToolDiscoveryProjection(req, res, tenantId, storyboardCompat); wrapSalesCapabilitiesProjection(req, res, tenantId, storyboardCompat); // Bridge `res.locals.trainingPrincipal` (set by the upstream @@ -447,6 +447,7 @@ function wrapSalesCapabilitiesProjection( function wrapTenantToolDiscoveryProjection( req: Request, res: Response, + tenantId: string, storyboardCompat?: TrainingContext['storyboardCompat'], ): void { if (req.body?.method !== 'tools/list') return; @@ -465,7 +466,7 @@ function wrapTenantToolDiscoveryProjection( (res as unknown as { end: (...args: unknown[]) => Response }).end = (chunk?: unknown, ...rest: unknown[]) => { if (chunk !== null && chunk !== undefined) chunks.push(toBuffer(chunk)); const body = Buffer.concat(chunks); - const patched = projectTenantToolDiscovery(body, storyboardCompat); + const patched = projectTenantToolDiscovery(body, tenantId, storyboardCompat); if (patched !== body && !res.headersSent) { res.setHeader('content-length', String(patched.length)); } @@ -475,18 +476,56 @@ function wrapTenantToolDiscoveryProjection( function projectTenantToolDiscovery( body: Buffer, + tenantId: string, storyboardCompat?: TrainingContext['storyboardCompat'], ): Buffer { try { const parsed = JSON.parse(body.toString('utf8')) as { result?: { - tools?: Array<{ name?: string }>; + tools?: Array<{ + name?: string; + inputSchema?: { + properties?: Record; + required?: unknown; + [key: string]: unknown; + }; + annotations?: Record; + }>; }; }; const tools = parsed.result?.tools; if (!Array.isArray(tools)) return body; - if (storyboardCompat?.version !== '3.0') return body; - parsed.result!.tools = tools.filter(tool => tool.name !== 'validate_input'); + if (tenantId === 'sales') { + const getProducts = tools.find(tool => tool.name === 'get_products'); + if (getProducts) { + const inputSchema = getProducts.inputSchema ?? {}; + const required = Array.isArray(inputSchema.required) + ? inputSchema.required.filter((value): value is string => typeof value === 'string') + : []; + getProducts.inputSchema = { + ...inputSchema, + properties: { + ...(inputSchema.properties ?? {}), + idempotency_key: { + type: 'string', + minLength: 16, + maxLength: 255, + pattern: '^[A-Za-z0-9_.:-]{16,255}$', + description: 'Client-generated key for this logical request. Reuse it unchanged for retries.', + }, + }, + required: [...new Set([...required, 'idempotency_key'])], + }; + getProducts.annotations = { + ...(getProducts.annotations ?? {}), + readOnlyHint: false, + idempotentHint: true, + }; + } + } + if (storyboardCompat?.version === '3.0') { + parsed.result!.tools = tools.filter(tool => tool.name !== 'validate_input'); + } return Buffer.from(JSON.stringify(parsed), 'utf8'); } catch { return body; diff --git a/server/src/training-agent/tenants/tenant-smoke.test.ts b/server/src/training-agent/tenants/tenant-smoke.test.ts index fe76003c1f..5d392894fe 100644 --- a/server/src/training-agent/tenants/tenant-smoke.test.ts +++ b/server/src/training-agent/tenants/tenant-smoke.test.ts @@ -807,4 +807,206 @@ describe('tenant routing smoke', () => { await close(); } }, 20000); + it('enforces and replays idempotency on polymorphic sales get_products', async () => { + const { baseUrl, close } = await bootServer(); + try { + const url = `${baseUrl}/sales/mcp`; + await initializeTenant(url); + const account = { + brand: { domain: 'tenant-products-idempotency.example' }, + operator: 'tenant-products-idempotency.example', + }; + const payload = { + idempotency_key: 'tenant-products-idempotency-0001', + buying_mode: 'wholesale', + account, + }; + + const listResponse = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + authorization: 'Bearer test-token', + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), + }); + const listBody = await listResponse.json() as { + result?: { + tools?: Array<{ + name?: string; + inputSchema?: { + properties?: Record>; + required?: string[]; + }; + annotations?: Record; + }>; + }; + }; + const discovered = listBody.result?.tools?.find(tool => tool.name === 'get_products'); + expect(discovered?.inputSchema?.required).toContain('idempotency_key'); + expect(discovered?.inputSchema?.properties?.idempotency_key).toMatchObject({ + type: 'string', + minLength: 16, + maxLength: 255, + pattern: '^[A-Za-z0-9_.:-]{16,255}$', + }); + expect(discovered?.annotations).toMatchObject({ readOnlyHint: false, idempotentHint: true }); + + const missing = await callTenantTool(url, 3, 'get_products', { + buying_mode: 'wholesale', + account, + }) as { result?: { structuredContent?: { adcp_error?: { code?: string; field?: string } } } }; + expect(missing.result?.structuredContent?.adcp_error?.code).toBe('INVALID_REQUEST'); + expect(missing.result?.structuredContent?.adcp_error?.field).toBe('idempotency_key'); + + const first = await callTenantTool(url, 4, 'get_products', payload) as { + result?: { structuredContent?: { products?: unknown[]; replayed?: boolean } }; + }; + const replay = await callTenantTool(url, 5, 'get_products', payload) as { + result?: { structuredContent?: { products?: unknown[]; replayed?: boolean } }; + }; + expect(first.result?.structuredContent?.products?.length).toBeGreaterThan(0); + expect(first.result?.structuredContent?.replayed).toBeUndefined(); + expect(replay.result?.structuredContent?.products).toEqual(first.result?.structuredContent?.products); + expect(replay.result?.structuredContent?.replayed).toBe(true); + + const invalid = await callTenantTool(url, 6, 'get_products', { + ...payload, + buying_mode: 'not-a-mode', + }) as { result?: { structuredContent?: { adcp_error?: { code?: string; field?: string } } } }; + expect(invalid.result?.structuredContent?.adcp_error).toMatchObject({ + code: 'INVALID_REQUEST', + field: 'buying_mode', + }); + + const mixedFinalize = await callTenantTool(url, 7, 'get_products', { + ...payload, + buying_mode: 'refine', + refine: [ + { scope: 'proposal', action: 'finalize', proposal_id: 'pinnacle_cross_channel' }, + { scope: 'proposal', action: 'include', proposal_id: 'pinnacle_cross_channel' }, + ], + }) as { result?: { structuredContent?: { adcp_error?: { code?: string; field?: string } } } }; + expect(mixedFinalize.result?.structuredContent?.adcp_error).toMatchObject({ + code: 'INVALID_REQUEST', + field: 'refine[1]', + }); + + const conflict = await callTenantTool(url, 8, 'get_products', { + ...payload, + buying_mode: 'brief', + brief: 'different logical request', + }) as { result?: { structuredContent?: { adcp_error?: { code?: string } } } }; + expect(conflict.result?.structuredContent?.adcp_error?.code).toBe('IDEMPOTENCY_CONFLICT'); + } finally { + await close(); + } + }, 15000); + + it('replays v6 get_products advisory-success responses', async () => { + const { baseUrl, close } = await bootServer(); + try { + const url = `${baseUrl}/sales/mcp`; + await initializeTenant(url); + const account = { + brand: { domain: 'tenant-products-advisory.example' }, + operator: 'tenant-products-advisory.example', + sandbox: true, + }; + const directive = await callTenantTool(url, 2, 'comply_test_controller', { + account, + scenario: 'force_upstream_unavailable', + params: { tool: 'get_products', upstream_name: 'catalog-test' }, + }) as { result?: { structuredContent?: { success?: boolean } } }; + expect(directive.result?.structuredContent?.success).toBe(true); + + const key = 'tenant-products-advisory-replay-0001'; + const first = await callTenantTool(url, 3, 'get_products', { + idempotency_key: key, + buying_mode: 'wholesale', + account, + context: { correlation_id: 'tenant-advisory-first' }, + }) as { + result?: { structuredContent?: { products?: unknown[]; errors?: Array<{ code?: string }>; context?: { correlation_id?: string } } }; + }; + const replay = await callTenantTool(url, 4, 'get_products', { + idempotency_key: key, + buying_mode: 'wholesale', + account, + context: { correlation_id: 'tenant-advisory-retry' }, + }) as { + result?: { structuredContent?: { products?: unknown[]; errors?: Array<{ code?: string }>; replayed?: boolean; context?: { correlation_id?: string } } }; + }; + expect(first.result?.structuredContent?.products?.length).toBeGreaterThan(0); + expect(first.result?.structuredContent?.errors?.[0]?.code).toBe('STALE_RESPONSE'); + expect(first.result?.structuredContent?.context?.correlation_id).toBe('tenant-advisory-first'); + expect(replay.result?.structuredContent?.replayed).toBe(true); + expect(replay.result?.structuredContent?.products).toEqual(first.result?.structuredContent?.products); + expect(replay.result?.structuredContent?.errors).toEqual(first.result?.structuredContent?.errors); + expect(replay.result?.structuredContent?.context?.correlation_id).toBe('tenant-advisory-retry'); + } finally { + await close(); + } + }, 15000); + + it('persists v6 proposal finalization before publishing its replay', async () => { + const { baseUrl, close } = await bootServer(); + try { + const url = `${baseUrl}/sales/mcp`; + await initializeTenant(url); + const account = { + brand: { domain: 'tenant-products-finalize.example' }, + operator: 'tenant-products-finalize.example', + }; + const brief = await callTenantTool(url, 2, 'get_products', { + idempotency_key: 'tenant-products-brief-finalize-0001', + buying_mode: 'brief', + brief: 'cross-channel news video and display', + account, + }) as { + result?: { structuredContent?: { proposals?: Array> } }; + }; + const draft = brief.result?.structuredContent?.proposals + ?.find(proposal => proposal.proposal_status === 'draft'); + expect(draft?.proposal_id).toBeTruthy(); + + const finalizePayload = { + idempotency_key: 'tenant-products-finalize-replay-0001', + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', proposal_id: draft!.proposal_id }], + }; + const finalized = await callTenantTool(url, 3, 'get_products', finalizePayload) as { + result?: { structuredContent?: { proposals?: Array>; replayed?: boolean } }; + }; + const committed = finalized.result?.structuredContent?.proposals + ?.find(proposal => proposal.proposal_id === draft!.proposal_id); + expect(committed).toMatchObject({ proposal_status: 'committed' }); + expect(committed?.expires_at).toBeTruthy(); + expect((committed?.insertion_order as Record | undefined)?.io_id).toBeTruthy(); + + // A fresh logical request must reload the committed proposal from the + // durable session rather than allocating a new hold/insertion order. + const reloaded = await callTenantTool(url, 4, 'get_products', { + ...finalizePayload, + idempotency_key: 'tenant-products-finalize-reload-0001', + }) as { + result?: { structuredContent?: { proposals?: Array> } }; + }; + const reloadedProposal = reloaded.result?.structuredContent?.proposals + ?.find(proposal => proposal.proposal_id === draft!.proposal_id); + expect(reloadedProposal).toEqual(committed); + + const replay = await callTenantTool(url, 5, 'get_products', finalizePayload) as { + result?: { structuredContent?: { proposals?: Array>; replayed?: boolean } }; + }; + const replayedProposal = replay.result?.structuredContent?.proposals + ?.find(proposal => proposal.proposal_id === draft!.proposal_id); + expect(replay.result?.structuredContent?.replayed).toBe(true); + expect(replayedProposal).toEqual(committed); + } finally { + await close(); + } + }, 15000); }); diff --git a/server/src/training-agent/v6-sales-platform.ts b/server/src/training-agent/v6-sales-platform.ts index 054f939a4f..11a6c53991 100644 --- a/server/src/training-agent/v6-sales-platform.ts +++ b/server/src/training-agent/v6-sales-platform.ts @@ -21,7 +21,7 @@ import { type AudienceStatus, } from '@adcp/sdk/server'; import { - handleGetProducts, + executeTrainingAgentTool, handleCreateMediaBuy, handleUpdateMediaBuy, handleGetMediaBuys, @@ -30,7 +30,6 @@ import { handleListCreatives, handleListCreativeFormats, hasAdcpSuccessPayload, - resolveServedAdcpVersion, } from './task-handlers.js'; import { handleProvidePerformanceFeedback, @@ -184,6 +183,30 @@ function translateV5Result(result: unknown, options: { allowAd return result as T; } +function throwGetProductsExecutionError(message: string): never { + const validationMatch = message.match(/^Invalid get_products request(?: at ([^:]+))?:/); + const invalidRequest = validationMatch !== null + || message.includes('idempotency_key') + || message.startsWith('brief must be a string'); + const code = message.includes('IDEMPOTENCY_CONFLICT') + ? 'IDEMPOTENCY_CONFLICT' + : message.includes('IDEMPOTENCY_EXPIRED') + ? 'IDEMPOTENCY_EXPIRED' + : message.includes('RATE_LIMITED') + ? 'RATE_LIMITED' + : invalidRequest + ? 'INVALID_REQUEST' + : 'SERVICE_UNAVAILABLE'; + const field = validationMatch?.[1] + ?? (message.startsWith('brief must be a string') ? 'brief' : undefined) + ?? (message.includes('idempotency_key') ? 'idempotency_key' : undefined); + throw new AdcpError(code, { + recovery: code === 'RATE_LIMITED' || code === 'SERVICE_UNAVAILABLE' ? 'transient' : 'correctable', + message, + ...(code === 'INVALID_REQUEST' && field && { field }), + }); +} + /** * Synthetic-account constructor — same posture as the signals tenant. * v6 mandates `accounts.resolve()` on every request; we synthesize an @@ -249,11 +272,18 @@ export class TrainingSalesPlatform // eslint-disable-next-line @typescript-eslint/no-explicit-any sales: SalesPlatform = { getProducts: async (req, ctx) => { - const versionResolution = resolveServedAdcpVersion(req as unknown as Record); - const trainingCtx = buildTrainingCtx(ctx, this.storyboardCompat); - if (versionResolution.ok) trainingCtx.servedAdcpVersion = versionResolution.servedVersion; - const result = await handleGetProducts(req as ToolArgs, trainingCtx); - return translateV5Result(result, { allowAdvisories: true }); + // The installed SDK predates polymorphic get_products idempotency. Route + // this one method through the shared schema-first dispatcher rather than + // mutating the SDK's global task classification: validation, session + // durability, and replay publication then share the same ordering as v5 + // and direct Addie dispatch. + const executed = await executeTrainingAgentTool( + 'get_products', + req as ToolArgs, + buildTrainingCtx(ctx, this.storyboardCompat), + ); + if (!executed.success) throwGetProductsExecutionError(executed.error ?? 'get_products failed'); + return translateV5Result(executed.data, { allowAdvisories: true }); }, createMediaBuy: async (req, ctx) => { diff --git a/server/tests/unit/addie/adcp-tools.test.ts b/server/tests/unit/addie/adcp-tools.test.ts index de334953d3..8fd04d83f2 100644 --- a/server/tests/unit/addie/adcp-tools.test.ts +++ b/server/tests/unit/addie/adcp-tools.test.ts @@ -268,6 +268,17 @@ describe('call_adcp_task handler validation boundary', () => { })).resolves.toContain('idempotency_key is required'); }); + it('rejects get_products before URL validation when idempotency_key is missing', async () => { + await expect(callAdcpTask?.({ + agent_url: 'http://example.com', + task: 'get_products', + params: { + buying_mode: 'wholesale', + account: { account_id: 'acct_123' }, + }, + })).resolves.toContain('idempotency_key is required'); + }); + it('rejects update_media_buy before URL validation when idempotency_key is missing', async () => { await expect(callAdcpTask?.({ agent_url: 'http://example.com', @@ -296,7 +307,34 @@ describe('call_adcp_task handler validation boundary', () => { }); describe('call_adcp_task training module isolation', () => { + it('forwards the exact caller-owned get_products idempotency key', async () => { + executeTrainingAgentTool.mockReset(); + executeTrainingAgentTool.mockResolvedValue({ success: true, data: { products: [] } }); + const handlers = createAdcpToolHandlers({ + workos_user: { workos_user_id: 'user_training' }, + } as any, { moduleId: 'S2' }); + const callAdcpTask = handlers.get('call_adcp_task'); + const params = { + idempotency_key: 'caller-owned-products-key', + buying_mode: 'wholesale', + account: { account_id: 'acct_123' }, + }; + + await callAdcpTask?.({ + agent_url: 'https://test-agent.adcontextprotocol.org/sales/mcp', + task: 'get_products', + params, + }); + + expect(executeTrainingAgentTool).toHaveBeenCalledWith( + 'get_products', + params, + expect.objectContaining({ mode: 'training', userId: 'user_training', moduleId: 'S2' }), + ); + }); + it('passes the shared current module to the embedded training agent', async () => { + executeTrainingAgentTool.mockReset(); executeTrainingAgentTool.mockResolvedValue({ success: true, data: { formats: [] } }); const trainingModuleContext = { moduleId: 'S1' }; const handlers = createAdcpToolHandlers({ diff --git a/server/tests/unit/idempotency.test.ts b/server/tests/unit/idempotency.test.ts index a2c117a4c6..61f8ccf7d8 100644 --- a/server/tests/unit/idempotency.test.ts +++ b/server/tests/unit/idempotency.test.ts @@ -72,6 +72,7 @@ describe('idempotency facade', () => { it('covers specific mutating tools explicitly', () => { for (const name of [ 'create_media_buy', 'update_media_buy', 'sync_audiences', + 'get_products', 'si_initiate_session', 'si_send_message', 'acquire_rights', 'update_rights', 'creative_approval', ]) { @@ -81,7 +82,6 @@ describe('idempotency facade', () => { it('excludes read-only and discovery tools', () => { for (const name of [ - 'get_products', 'get_media_buys', 'get_adcp_capabilities', 'check_governance', diff --git a/server/tests/unit/mcp-eval-tools.test.ts b/server/tests/unit/mcp-eval-tools.test.ts index f0885b508d..290931fca7 100644 --- a/server/tests/unit/mcp-eval-tools.test.ts +++ b/server/tests/unit/mcp-eval-tools.test.ts @@ -8,6 +8,20 @@ * - Auth-required tools reject anonymous callers */ import { afterEach, describe, it, expect, vi } from 'vitest'; + +const executeTask = vi.hoisted(() => vi.fn()); + +vi.mock('@adcp/sdk', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + AdCPClient: class MockAdCPClient { + agent() { + return { executeTask }; + } + }, + }; +}); import * as hostnameVerification from '../../src/services/agent-hostname-verification.js'; import { AgentContextDatabase } from '../../src/db/agent-context-db.js'; import { MemberDatabase } from '../../src/db/member-db.js'; @@ -23,10 +37,13 @@ import { createMemberToolHandler, createStatelessToolHandlers, } from '../../src/mcp/exposed-tools.js'; -import { createMemberToolHandlers } from '../../src/addie/mcp/member-tools.js'; +import { MEMBER_TOOLS, createMemberToolHandlers } from '../../src/addie/mcp/member-tools.js'; import type { MemberContext } from '../../src/addie/member-context.js'; afterEach(() => { + executeTask.mockReset(); + vi.clearAllTimers(); + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -104,11 +121,19 @@ describe('EVAL_TOOL_DEFINITIONS', () => { it('test_rfp_response requires rfp parameter', () => { const tool = EVAL_TOOL_DEFINITIONS.find((t) => t.name === 'test_rfp_response'); expect(tool!.inputSchema.required).toContain('rfp'); + expect(tool!.inputSchema.required).toContain('idempotency_key'); }); it('test_io_execution requires line_items parameter', () => { const tool = EVAL_TOOL_DEFINITIONS.find((t) => t.name === 'test_io_execution'); expect(tool!.inputSchema.required).toContain('line_items'); + expect(tool!.inputSchema.required).toContain('idempotency_key'); + expect(tool!.inputSchema.required).toContain('create_media_buy_idempotency_key'); + }); + + it('compare_media_kit requires a caller-owned idempotency key prefix', () => { + const tool = MEMBER_TOOLS.find((t) => t.name === 'compare_media_kit'); + expect(tool!.input_schema.required).toContain('idempotency_key_prefix'); }); it('evaluate_agent_quality has tracks param', () => { @@ -117,6 +142,81 @@ describe('EVAL_TOOL_DEFINITIONS', () => { }); }); +describe('evaluation tool idempotency forwarding', () => { + const agentUrl = 'https://test-agent.adcontextprotocol.org/sales/mcp'; + + it('forwards the exact get_products key from test_rfp_response', async () => { + vi.useFakeTimers(); + executeTask.mockResolvedValue({ success: true, data: { products: [], proposals: [] } }); + const handler = createMemberToolHandlers(null).get('test_rfp_response'); + + await handler?.({ + agent_url: agentUrl, + idempotency_key: 'rfp-caller-owned-key', + rfp: { brief: 'Reach news readers with display inventory.' }, + }); + + expect(executeTask).toHaveBeenCalledWith('get_products', expect.objectContaining({ + idempotency_key: 'rfp-caller-owned-key', + })); + }); + + it('reuses caller-owned catalog and create-media-buy keys in test_io_execution', async () => { + vi.useFakeTimers(); + executeTask.mockResolvedValueOnce({ + success: true, + data: { + products: [{ + product_id: 'display-product', + name: 'Display inventory', + channels: ['display'], + pricing_options: [{ pricing_option_id: 'display-cpm', pricing_model: 'cpm', price: 10 }], + }], + proposals: [], + }, + }).mockResolvedValueOnce({ + success: true, + data: { media_buy_id: 'mb_123', status: 'active', packages: [{}] }, + }); + const handler = createMemberToolHandlers(null).get('test_io_execution'); + + await handler?.({ + agent_url: agentUrl, + idempotency_key: 'io-catalog-caller-key', + create_media_buy_idempotency_key: 'io-create-caller-key', + execute: true, + line_items: [{ description: 'Display inventory', channel: 'display', budget: 1000 }], + }); + + expect(executeTask).toHaveBeenNthCalledWith(1, 'get_products', expect.objectContaining({ + idempotency_key: 'io-catalog-caller-key', + })); + expect(executeTask).toHaveBeenNthCalledWith(2, 'create_media_buy', expect.objectContaining({ + idempotency_key: 'io-create-caller-key', + })); + }); + + it('derives stable per-brief keys from compare_media_kit prefix', async () => { + executeTask.mockResolvedValue({ success: true, data: { products: [] } }); + const handler = createMemberToolHandlers(null).get('compare_media_kit'); + + await handler?.({ + agent_url: agentUrl, + idempotency_key_prefix: 'media-kit-caller-prefix', + media_kit_summary: 'Display and video inventory.', + verticals: ['automotive', 'healthcare'], + }); + + expect(executeTask).toHaveBeenCalledTimes(2); + expect(executeTask).toHaveBeenNthCalledWith(1, 'get_products', expect.objectContaining({ + idempotency_key: 'media-kit-caller-prefix:0', + })); + expect(executeTask).toHaveBeenNthCalledWith(2, 'get_products', expect.objectContaining({ + idempotency_key: 'media-kit-caller-prefix:1', + })); + }); +}); + describe('AGENT_CONTEXT_TOOL_DEFINITIONS', () => { const EXPECTED = ['save_agent', 'list_saved_agents', 'remove_saved_agent']; diff --git a/server/tests/unit/training-agent-idempotency.test.ts b/server/tests/unit/training-agent-idempotency.test.ts index c20e5f309d..a8da5a7d3e 100644 --- a/server/tests/unit/training-agent-idempotency.test.ts +++ b/server/tests/unit/training-agent-idempotency.test.ts @@ -11,15 +11,21 @@ * on retry. */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { createTrainingAgentServer, + executeTrainingAgentTool, invalidateCache, clearTaskStore, } from '../../src/training-agent/task-handlers.js'; -import { clearSessions } from '../../src/training-agent/state.js'; -import { MUTATING_TOOLS, clearIdempotencyCache } from '../../src/training-agent/idempotency.js'; +import { clearSessions, getSession } from '../../src/training-agent/state.js'; +import { + MUTATING_TOOLS, + REPLAY_TTL_SECONDS, + clearIdempotencyCache, + getIdempotencyStore, +} from '../../src/training-agent/idempotency.js'; import type { TrainingContext } from '../../src/training-agent/types.js'; const CTX: TrainingContext = { mode: 'open', principal: 'test-principal' }; @@ -46,6 +52,35 @@ async function call( return { parsed, isError: response.isError }; } +async function callAsTask( + server: ReturnType, + toolName: string, + args: Record, + ttl = 60_000, +): Promise<{ parsed: Record; isError?: boolean }> { + const requestHandlers = (server as any)._requestHandlers as Map; + const handler = requestHandlers.get('tools/call'); + if (!handler) throw new Error('CallTool handler not found'); + const response = await handler( + { method: 'tools/call', params: { name: toolName, arguments: args, task: { ttl } } }, + {}, + ); + return { + parsed: (response.structuredContent ?? response) as Record, + isError: response.isError, + }; +} + +async function taskResult( + server: ReturnType, + taskId: string, +): Promise> { + const requestHandlers = (server as any)._requestHandlers as Map; + const handler = requestHandlers.get('tasks/result'); + if (!handler) throw new Error('tasks/result handler not found'); + return handler({ method: 'tasks/result', params: { taskId } }, {}); +} + const basePayload = () => ({ account: ACCOUNT, brand: BRAND, @@ -58,6 +93,7 @@ async function getValidProductAndPricing( server: ReturnType, ): Promise<{ productId: string; pricingOptionId: string }> { const { parsed } = await call(server, 'get_products', { + idempotency_key: `catalog-${randomUUID()}`, buying_mode: 'wholesale', account: ACCOUNT, brand: BRAND, @@ -104,13 +140,15 @@ describe('training agent idempotency middleware', () => { expect((parsed as any).adcp_error?.code).toBe('INVALID_REQUEST'); }); - it('does not require idempotency_key on read-only tools', async () => { - const { isError } = await call(server, 'get_products', { + it('rejects get_products with no idempotency_key before any polymorphic arm runs', async () => { + const { parsed, isError } = await call(server, 'get_products', { buying_mode: 'wholesale', account: ACCOUNT, brand: BRAND, }); - expect(isError).toBeFalsy(); + expect(isError).toBe(true); + expect((parsed as any).adcp_error?.code).toBe('INVALID_REQUEST'); + expect((parsed as any).adcp_error?.field).toBe('idempotency_key'); }); }); @@ -213,6 +251,467 @@ describe('training agent idempotency middleware', () => { }); expect((replay.parsed as any).replayed).toBe(true); }); + + it('replays a task-augmented get_products response without allocating another task', async () => { + const key = `products-task-${randomUUID()}`; + const payload = { + idempotency_key: key, + buying_mode: 'wholesale', + account: ACCOUNT, + brand: BRAND, + }; + + const first = await callAsTask(server, 'get_products', payload); + const firstTaskId = (first.parsed.task as { taskId?: string })?.taskId; + expect(firstTaskId).toBeTruthy(); + + const replay = await callAsTask(server, 'get_products', { ...payload }); + expect((replay.parsed.task as { taskId?: string })?.taskId).toBe(firstTaskId); + expect(replay.parsed.replayed).toBe(true); + + const conflict = await call(server, 'get_products', { + ...payload, + buying_mode: 'brief', + brief: 'different logical request', + }); + expect(conflict.isError).toBe(true); + expect((conflict.parsed as any).adcp_error?.code).toBe('IDEMPOTENCY_CONFLICT'); + }); + + it('validates the complete get_products payload before consulting the cache', async () => { + const key = `products-schema-first-${randomUUID()}`; + await call(server, 'get_products', { + idempotency_key: key, + buying_mode: 'wholesale', + account: ACCOUNT, + }); + const invalid = await call(server, 'get_products', { + idempotency_key: key, + buying_mode: 'not-a-mode', + account: ACCOUNT, + }); + expect(invalid.isError).toBe(true); + expect((invalid.parsed as any).adcp_error?.code).toBe('INVALID_REQUEST'); + expect((invalid.parsed as any).adcp_error?.code).not.toBe('IDEMPOTENCY_CONFLICT'); + }); + + it('rejects mixed proposal finalization before reserving the idempotency key', async () => { + const account = { brand: { domain: 'idem-finalize-exclusive.example' }, operator: 'idem-op' }; + await call(server, 'get_products', { + idempotency_key: `products-brief-${randomUUID()}`, + buying_mode: 'brief', + brief: 'cross-channel news video and display', + account, + }); + const key = `products-finalize-exclusive-${randomUUID()}`; + const invalid = await call(server, 'get_products', { + idempotency_key: key, + buying_mode: 'refine', + account, + refine: [ + { scope: 'proposal', action: 'finalize', proposal_id: 'pinnacle_cross_channel' }, + { scope: 'proposal', action: 'include', proposal_id: 'pinnacle_cross_channel' }, + ], + }); + expect(invalid.isError).toBe(true); + expect((invalid.parsed as any).adcp_error).toMatchObject({ + code: 'INVALID_REQUEST', + field: 'refine[1]', + }); + + // The invalid request never reserved the key, so the corrected finalize + // can use that same key without IDEMPOTENCY_CONFLICT. + const corrected = await call(server, 'get_products', { + idempotency_key: key, + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', proposal_id: 'pinnacle_cross_channel' }], + }); + expect(corrected.isError).toBeFalsy(); + expect((corrected.parsed.proposals as Array>) + .find(proposal => proposal.proposal_id === 'pinnacle_cross_channel')) + .toMatchObject({ proposal_status: 'committed' }); + }); + + it('allocates a fresh task when a released error key is retried with corrected input', async () => { + const key = `products-task-correction-${randomUUID()}`; + const failed = await callAsTask(server, 'get_products', { + idempotency_key: key, + buying_mode: 'refine', + account: ACCOUNT, + refine: [{ scope: 'proposal', action: 'finalize', proposal_id: 'proposal-does-not-exist' }], + }); + const failedTaskId = (failed.parsed.task as { taskId?: string })?.taskId; + expect(failedTaskId).toBeTruthy(); + + const correctedPayload = { + idempotency_key: key, + buying_mode: 'wholesale', + account: ACCOUNT, + }; + const corrected = await callAsTask(server, 'get_products', correctedPayload); + const correctedTaskId = (corrected.parsed.task as { taskId?: string })?.taskId; + expect(correctedTaskId).toBeTruthy(); + expect(correctedTaskId).not.toBe(failedTaskId); + + const replay = await callAsTask(server, 'get_products', correctedPayload); + expect((replay.parsed.task as { taskId?: string })?.taskId).toBe(correctedTaskId); + expect(replay.parsed.replayed).toBe(true); + }); + + it('allocates a fresh task when the exact failed payload later succeeds', async () => { + const account = { brand: { domain: 'idem-task-recovery.example' }, operator: 'idem-op' }; + await call(server, 'get_products', { + idempotency_key: `products-brief-${randomUUID()}`, + buying_mode: 'brief', + brief: 'cross-channel news video and display', + account, + }); + const payloads = [ + { + idempotency_key: `products-task-finalize-left-${randomUUID()}`, + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', proposal_id: 'pinnacle_cross_channel' }], + }, + { + idempotency_key: `products-task-finalize-right-${randomUUID()}`, + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', proposal_id: 'pinnacle_cross_channel' }], + }, + ]; + const firstAttempts = await Promise.all(payloads.map(payload => callAsTask(server, 'get_products', payload))); + const firstResults = await Promise.all(firstAttempts.map(async attempt => { + const taskId = (attempt.parsed.task as { taskId: string }).taskId; + return { taskId, result: await taskResult(server, taskId) }; + })); + const failedIndex = firstResults.findIndex(({ result }) => result.isError === true); + expect(failedIndex).toBeGreaterThanOrEqual(0); + expect((firstResults[failedIndex]!.result.structuredContent as any)?.adcp_error?.code).toBe('RATE_LIMITED'); + + const recovered = await callAsTask(server, 'get_products', payloads[failedIndex]!); + const recoveredTaskId = (recovered.parsed.task as { taskId: string }).taskId; + expect(recoveredTaskId).not.toBe(firstResults[failedIndex]!.taskId); + const recoveredResult = await taskResult(server, recoveredTaskId); + expect(recoveredResult.isError).not.toBe(true); + expect((recoveredResult.structuredContent as { proposals?: unknown[] } | undefined)?.proposals?.length) + .toBeGreaterThan(0); + + const replay = await callAsTask(server, 'get_products', payloads[failedIndex]!); + expect((replay.parsed.task as { taskId: string }).taskId).toBe(recoveredTaskId); + expect(replay.parsed.replayed).toBe(true); + }); + + it('does not collapse identical non-idempotency-protected task calls', async () => { + const first = await callAsTask(server, 'get_signals', {}); + const second = await callAsTask(server, 'get_signals', {}); + expect((first.parsed.task as { taskId: string }).taskId).not.toBe( + (second.parsed.task as { taskId: string }).taskId, + ); + }); + + it('recovers a persisted successful task before rerunning the handler after cache-save failure', async () => { + const { productId, pricingOptionId } = await getValidProductAndPricing(server); + const key = `products-task-cache-failure-${randomUUID()}`; + const payload = { + ...basePayload(), + packages: [{ product_id: productId, budget: 5000, pricing_option_id: pricingOptionId }], + idempotency_key: key, + }; + const store = getIdempotencyStore(); + const saveFailure = vi.spyOn(store, 'save').mockRejectedValueOnce(new Error('injected cache save failure')); + vi.useFakeTimers(); + try { + const requestedTtl = 25; + await expect(callAsTask(server, 'create_media_buy', payload, requestedTtl)) + .rejects.toThrow('injected cache save failure'); + + // The public task request suggested a 25ms lifetime, but MCP permits + // servers to override that suggestion. Successful idempotency receipts + // report and retain the actual replay-window TTL; ordinary tasks still + // honor their short requested lifetime. + const ordinary = await callAsTask(server, 'get_signals', {}, requestedTtl); + expect((ordinary.parsed.task as { ttl?: number }).ttl).toBe(requestedTtl); + await vi.advanceTimersByTimeAsync(1_000); + + const recovered = await callAsTask(server, 'create_media_buy', { + ...payload, + context: { correlation_id: 'cache-recovery-retry' }, + }, requestedTtl); + expect((recovered.parsed.task as { ttl?: number }).ttl) + .toBeGreaterThanOrEqual((REPLAY_TTL_SECONDS + 60) * 1000); + expect(recovered.parsed.replayed).toBe(true); + expect((recovered.parsed.context as { correlation_id?: string })?.correlation_id) + .toBe('cache-recovery-retry'); + const recoveredTaskId = (recovered.parsed.task as { taskId: string }).taskId; + const recoveredResult = await taskResult(server, recoveredTaskId); + const originalMediaBuyId = (recoveredResult.structuredContent as { media_buy_id?: string })?.media_buy_id; + expect(originalMediaBuyId).toBeTruthy(); + + const persistedSession = await getSession('open:idem-test.example'); + expect(persistedSession.mediaBuys.size).toBe(1); + expect([...persistedSession.mediaBuys.keys()]).toEqual([originalMediaBuyId]); + expect(saveFailure).toHaveBeenCalledTimes(2); + } finally { + saveFailure.mockRestore(); + vi.useRealTimers(); + } + }); + + it('replays proposal finalization with the original hold and insertion order', async () => { + const account = { brand: { domain: 'idem-finalize.example' }, operator: 'idem-op' }; + await call(server, 'get_products', { + idempotency_key: `products-brief-${randomUUID()}`, + buying_mode: 'brief', + brief: 'cross-channel news video and display', + account, + }); + + const missing = await call(server, 'get_products', { + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', proposal_id: 'pinnacle_cross_channel' }], + }); + expect(missing.isError).toBe(true); + expect((missing.parsed as any).adcp_error?.field).toBe('idempotency_key'); + + const key = `products-finalize-${randomUUID()}`; + const payload = { + idempotency_key: key, + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', proposal_id: 'pinnacle_cross_channel' }], + }; + const first = await call(server, 'get_products', payload); + const firstProposal = (first.parsed.proposals as Array>) + .find((proposal) => proposal.proposal_id === 'pinnacle_cross_channel'); + expect(firstProposal?.proposal_status).toBe('committed'); + expect(firstProposal?.expires_at).toBeTruthy(); + expect((firstProposal?.insertion_order as Record)?.io_id).toBeTruthy(); + + const replay = await call(server, 'get_products', { ...payload }); + const replayProposal = (replay.parsed.proposals as Array>) + .find((proposal) => proposal.proposal_id === 'pinnacle_cross_channel'); + expect(replay.parsed.replayed).toBe(true); + expect(replayProposal).toEqual(firstProposal); + + const conflict = await call(server, 'get_products', { + ...payload, + refine: [{ + scope: 'proposal', + action: 'finalize', + proposal_id: 'pinnacle_cross_channel', + ask: 'changed payload', + }], + }); + expect(conflict.isError).toBe(true); + expect((conflict.parsed as any).adcp_error?.code).toBe('IDEMPOTENCY_CONFLICT'); + }); + + it('serializes parallel proposal-finalize retries into one execution and one replay', async () => { + const account = { brand: { domain: 'idem-concurrent-finalize.example' }, operator: 'idem-op' }; + await call(server, 'get_products', { + idempotency_key: `products-brief-${randomUUID()}`, + buying_mode: 'brief', + brief: 'cross-channel news video and display', + account, + }); + const payload = { + idempotency_key: `products-finalize-${randomUUID()}`, + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', proposal_id: 'pinnacle_cross_channel' }], + }; + + const [left, right] = await Promise.all([ + call(server, 'get_products', payload), + call(server, 'get_products', payload), + ]); + const outcomes = [left, right]; + expect(outcomes.filter((outcome) => outcome.isError !== true)).toHaveLength(1); + const successful = outcomes.find((outcome) => outcome.isError !== true)!; + const limited = outcomes.find((outcome) => outcome.isError === true)!; + expect((limited.parsed as any).adcp_error?.code).toBe('RATE_LIMITED'); + + const replay = await call(server, 'get_products', payload); + expect(replay.parsed.replayed).toBe(true); + const successfulProposal = (successful.parsed.proposals as Array>) + .find((proposal) => proposal.proposal_id === 'pinnacle_cross_channel'); + const replayProposal = (replay.parsed.proposals as Array>) + .find((proposal) => proposal.proposal_id === 'pinnacle_cross_channel'); + expect(replayProposal).toEqual(successfulProposal); + }); + + it('serializes proposal finalization across distinct request keys', async () => { + const account = { brand: { domain: 'idem-distinct-finalize.example' }, operator: 'idem-op' }; + await call(server, 'get_products', { + idempotency_key: `products-brief-${randomUUID()}`, + buying_mode: 'brief', + brief: 'cross-channel news video and display', + account, + }); + const base = { + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', proposal_id: 'pinnacle_cross_channel' }], + }; + const payloads = [ + { ...base, idempotency_key: `products-finalize-left-${randomUUID()}` }, + { ...base, idempotency_key: `products-finalize-right-${randomUUID()}` }, + ]; + const outcomes = await Promise.all(payloads.map(payload => call(server, 'get_products', payload))); + const successful = outcomes.find(outcome => outcome.isError !== true)!; + const limitedIndex = outcomes.findIndex(outcome => outcome.isError === true); + expect(successful).toBeTruthy(); + expect(limitedIndex).toBeGreaterThanOrEqual(0); + expect((outcomes[limitedIndex]!.parsed as any).adcp_error?.code).toBe('RATE_LIMITED'); + + const retry = await call(server, 'get_products', payloads[limitedIndex]!); + expect(retry.isError).toBeFalsy(); + const successfulProposal = (successful.parsed.proposals as Array>) + .find(proposal => proposal.proposal_id === 'pinnacle_cross_channel'); + const retryProposal = (retry.parsed.proposals as Array>) + .find(proposal => proposal.proposal_id === 'pinnacle_cross_channel'); + expect(retryProposal?.insertion_order).toEqual(successfulProposal?.insertion_order); + expect(retryProposal?.expires_at).toBe(successfulProposal?.expires_at); + }); + + it('serializes overlapping proposal-finalize sets without double-committing either proposal', async () => { + const account = { brand: { domain: 'idem-overlap-finalize.example' }, operator: 'idem-op' }; + const singlePayload = { + idempotency_key: `products-finalize-single-${randomUUID()}`, + buying_mode: 'refine', + account, + refine: [ + { scope: 'proposal', action: 'finalize', proposal_id: 'pinnacle_cross_channel' }, + ], + }; + const overlappingPayload = { + idempotency_key: `products-finalize-overlap-${randomUUID()}`, + buying_mode: 'refine', + account, + refine: [ + { scope: 'proposal', action: 'finalize', proposal_id: 'pinnacle_cross_channel' }, + { scope: 'proposal', action: 'finalize', proposal_id: 'viewpoint_multi_screen' }, + ], + }; + const payloads = [singlePayload, overlappingPayload]; + const outcomes = await Promise.all(payloads.map(payload => call(server, 'get_products', payload))); + const limitedIndex = outcomes.findIndex(outcome => outcome.isError === true); + expect(limitedIndex).toBeGreaterThanOrEqual(0); + expect((outcomes[limitedIndex]!.parsed as any).adcp_error?.code).toBe('RATE_LIMITED'); + + outcomes[limitedIndex] = await call(server, 'get_products', payloads[limitedIndex]!); + expect(outcomes.every(outcome => outcome.isError !== true)).toBe(true); + + const singleReplay = await call(server, 'get_products', singlePayload); + const overlappingReplay = await call(server, 'get_products', overlappingPayload); + expect(singleReplay.parsed.replayed).toBe(true); + expect(overlappingReplay.parsed.replayed).toBe(true); + + const proposal = (result: { parsed: Record }, proposalId: string) => ( + result.parsed.proposals as Array> + ).find(candidate => candidate.proposal_id === proposalId); + const firstPinnacle = proposal(outcomes[0]!, 'pinnacle_cross_channel'); + const overlappingPinnacle = proposal(outcomes[1]!, 'pinnacle_cross_channel'); + const overlappingViewpoint = proposal(outcomes[1]!, 'viewpoint_multi_screen'); + expect(firstPinnacle?.proposal_status).toBe('committed'); + expect(overlappingPinnacle?.proposal_status).toBe('committed'); + expect(overlappingViewpoint?.proposal_status).toBe('committed'); + expect(proposal(singleReplay, 'pinnacle_cross_channel')).toEqual(firstPinnacle); + expect(proposal(overlappingReplay, 'pinnacle_cross_channel')).toEqual(overlappingPinnacle); + expect(proposal(overlappingReplay, 'viewpoint_multi_screen')).toEqual(overlappingViewpoint); + expect(overlappingPinnacle?.insertion_order).toEqual(firstPinnacle?.insertion_order); + expect(overlappingPinnacle?.expires_at).toBe(firstPinnacle?.expires_at); + }); + }); + + describe('in-process Addie dispatch', () => { + it('enforces and replays get_products idempotency instead of bypassing the middleware', async () => { + const ctx: TrainingContext = { mode: 'training', principal: 'addie-test' }; + const missing = await executeTrainingAgentTool('get_products', { + buying_mode: 'wholesale', + account: ACCOUNT, + }, ctx); + expect(missing.success).toBe(false); + expect(missing.error).toContain('idempotency_key'); + + const payload = { + idempotency_key: `addie-products-${randomUUID()}`, + buying_mode: 'wholesale', + account: ACCOUNT, + }; + const first = await executeTrainingAgentTool('get_products', payload, ctx); + const replay = await executeTrainingAgentTool('get_products', payload, ctx); + expect(first.success).toBe(true); + expect(replay.success).toBe(true); + expect((replay.data as Record).replayed).toBe(true); + expect((replay.data as Record).products) + .toEqual((first.data as Record).products); + }); + + it('caches advisory-success results and echoes the current replay context', async () => { + const ctx: TrainingContext = { mode: 'open', principal: 'test-principal' }; + const directive = await call(server, 'comply_test_controller', { + account: ACCOUNT, + scenario: 'force_upstream_unavailable', + params: { tool: 'get_products', upstream_name: 'catalog-test' }, + }); + expect((directive.parsed as { success?: boolean }).success).toBe(true); + + const key = `addie-advisory-${randomUUID()}`; + const first = await executeTrainingAgentTool('get_products', { + idempotency_key: key, + buying_mode: 'wholesale', + account: ACCOUNT, + context: { correlation_id: 'advisory-first' }, + }, ctx); + const replay = await executeTrainingAgentTool('get_products', { + idempotency_key: key, + buying_mode: 'wholesale', + account: ACCOUNT, + context: { correlation_id: 'advisory-retry' }, + }, ctx); + expect(first.success).toBe(true); + expect((first.data as any).errors?.[0]?.code).toBe('STALE_RESPONSE'); + expect((first.data as any).context?.correlation_id).toBe('advisory-first'); + expect(replay.success).toBe(true); + expect((replay.data as any).replayed).toBe(true); + expect((replay.data as any).errors).toEqual((first.data as any).errors); + expect((replay.data as any).products).toEqual((first.data as any).products); + expect((replay.data as any).context?.correlation_id).toBe('advisory-retry'); + }); + + it('persists direct Addie finalization before publishing its replay', async () => { + const ctx: TrainingContext = { mode: 'training', principal: 'addie-finalize-test' }; + const account = { brand: { domain: 'addie-finalize.example' }, operator: 'addie-op' }; + await executeTrainingAgentTool('get_products', { + idempotency_key: `addie-brief-${randomUUID()}`, + buying_mode: 'brief', + brief: 'cross-channel news video and display', + account, + }, ctx); + const finalize = (idempotencyKey: string) => executeTrainingAgentTool('get_products', { + idempotency_key: idempotencyKey, + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', proposal_id: 'pinnacle_cross_channel' }], + }, ctx); + const key = `addie-finalize-${randomUUID()}`; + const first = await finalize(key); + const replay = await finalize(key); + const reloaded = await finalize(`addie-finalize-reload-${randomUUID()}`); + expect(first.success).toBe(true); + expect(replay.success).toBe(true); + expect(reloaded.success).toBe(true); + const proposal = (result: typeof first) => ( + (result.data as { proposals?: Array> }).proposals ?? [] + ).find(item => item.proposal_id === 'pinnacle_cross_channel'); + expect((replay.data as Record).replayed).toBe(true); + expect(proposal(reloaded)?.insertion_order).toEqual(proposal(first)?.insertion_order); + expect(proposal(reloaded)?.expires_at).toBe(proposal(first)?.expires_at); + }); }); describe('key reuse with different payload', () => { diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index 08883acbed..ee3722284e 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -11243,18 +11243,20 @@ describe('MCP Tasks protocol', () => { expect(task.status).toBe('completed'); expect(task.createdAt).toBeDefined(); expect(task.lastUpdatedAt).toBeDefined(); - // Defaults to 15 minutes when no TTL requested (clamped by server) - expect(task.ttl).toBe(900_000); + // Successful idempotency-protected tasks are crash-recovery receipts. + // MCP permits the server to override the requested TTL, so their reported + // lifetime covers the full replay window plus clock-skew allowance. + expect(task.ttl).toBe((REPLAY_TTL_SECONDS + 60) * 1000); }); - it('respects requested TTL', async () => { + it('overrides requested TTL for successful idempotency recovery receipts', async () => { const server = createTrainingAgentServer(DEFAULT_CTX); const response = await simulateCallToolAsTask(server, 'get_products', { buying_mode: 'wholesale', }, { ttl: 120000 }); const task = response.task as Record; - expect(task.ttl).toBe(120000); + expect(task.ttl).toBe((REPLAY_TTL_SECONDS + 60) * 1000); }); it('retrieves task status via tasks/get', async () => { @@ -11413,21 +11415,27 @@ describe('MCP Tasks protocol', () => { ).rejects.toThrow(/terminal status/); }); - it('expires tasks after TTL', async () => { - const server = createTrainingAgentServer(DEFAULT_CTX); - const response = await simulateCallToolAsTask(server, 'get_products', { - buying_mode: 'wholesale', - }, { ttl: 1 }); // 1ms TTL - - const taskId = (response.task as Record).taskId as string; - - // Wait just enough for TTL to expire - await new Promise(r => setTimeout(r, 10)); - - // tasks/get triggers cleanup — expired task should be gone - await expect( - simulateGetTask(server, taskId), - ).rejects.toThrow('Task not found'); + it('retains successful idempotency receipts beyond the requested TTL', async () => { + vi.useFakeTimers(); + try { + const server = createTrainingAgentServer(DEFAULT_CTX); + const response = await simulateCallToolAsTask(server, 'get_products', { + buying_mode: 'wholesale', + }, { ttl: 1 }); // caller suggests a 1ms TTL + + const task = response.task as Record; + const taskId = task.taskId as string; + expect(task.ttl).toBe((REPLAY_TTL_SECONDS + 60) * 1000); + + // Advance well past the caller's suggestion but remain inside the + // idempotency replay window. The recovery receipt must still be visible. + await vi.advanceTimersByTimeAsync(10); + const retained = await simulateGetTask(server, taskId); + expect(retained.taskId).toBe(taskId); + expect(retained.status).toBe('completed'); + } finally { + vi.useRealTimers(); + } }); it('non-task-augmented calls still return direct results', async () => { @@ -13014,7 +13022,9 @@ describe('context echo', () => { // except as the echoed context field const { parsed } = await simulateCallToolRaw(server, 'get_products', { context: TEST_CONTEXT, - account: { brand: { domain: 'acmeoutdoor.com' } }, + idempotency_key: 'context-echo-products-0001', + buying_mode: 'wholesale', + account: { brand: { domain: 'acmeoutdoor.com' }, operator: 'test-operator.example' }, }); expect(parsed.context).toEqual(TEST_CONTEXT); // The products field should exist (handler ran successfully) diff --git a/static/compliance/source/protocols/governance/index.yaml b/static/compliance/source/protocols/governance/index.yaml index 75c2d6d0e9..d54296ec18 100644 --- a/static/compliance/source/protocols/governance/index.yaml +++ b/static/compliance/source/protocols/governance/index.yaml @@ -323,6 +323,7 @@ phases: - forecast: estimated delivery sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_governance_escalation_discover_products_get_products_brief" buying_mode: "brief" brief: "Premium CTV and video on sports publishers. Q2 flight, $50K budget. Adults 25-54, US." brand: diff --git a/static/compliance/source/protocols/media-buy/index.yaml b/static/compliance/source/protocols/media-buy/index.yaml index 4c7d83a9b1..eccafb3275 100644 --- a/static/compliance/source/protocols/media-buy/index.yaml +++ b/static/compliance/source/protocols/media-buy/index.yaml @@ -313,6 +313,7 @@ phases: If the brief is unclear, return input-required with clarifying questions. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_product_discovery_get_products_brief" buying_mode: "brief" brief: "Premium video inventory on sports and outdoor lifestyle publishers. Q2 flight, $50K budget. Adults 25-54, US and Canada." pagination: diff --git a/static/compliance/source/protocols/media-buy/scenarios/audience_buy_flow.yaml b/static/compliance/source/protocols/media-buy/scenarios/audience_buy_flow.yaml index 180acba16b..ebfa2bd544 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/audience_buy_flow.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/audience_buy_flow.yaml @@ -150,6 +150,7 @@ phases: Return at least one product whose audience_targeting capabilities indicate audience_include / audience_exclude overlays are accepted. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_audience_buy_flow_setup_get_products_for_audience" buying_mode: "brief" brief: "Display inventory, US adults 25-54, restricted to a first-party CRM audience. Q2 flight, ~$15K budget." required_capabilities: diff --git a/static/compliance/source/protocols/media-buy/scenarios/available_actions.yaml b/static/compliance/source/protocols/media-buy/scenarios/available_actions.yaml index 0c6b55a1c1..08916c661a 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/available_actions.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/available_actions.yaml @@ -123,6 +123,7 @@ phases: Return the seeded product with allowed_actions[] entries for increase_budget, extend_flight, and cancel. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_available_actions_discover_product_action_template_get_product_allowed_actions" buying_mode: brief brief: "available actions display package" filters: diff --git a/static/compliance/source/protocols/media-buy/scenarios/canonical_formats.yaml b/static/compliance/source/protocols/media-buy/scenarios/canonical_formats.yaml index c487d15581..731262122f 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/canonical_formats.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/canonical_formats.yaml @@ -350,6 +350,7 @@ phases: - params.width = 300 and params.height = 250 sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_canonical_formats_canonical_product_discovery_get_seeded_canonical_product" buying_mode: "wholesale" filters: channels: ["display"] @@ -450,6 +451,7 @@ phases: v1 fallback format_id. No producer-side `format_options[]` projection is required for v1-only products. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_canonical_formats_canonical_product_discovery_get_seeded_v1_only_product" buying_mode: "wholesale" filters: channels: ["display"] @@ -498,6 +500,7 @@ phases: Return the seeded canonical_formats_options_only_display product with an image canonical declaration and no format_ids fallback. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_canonical_formats_canonical_product_discovery_get_seeded_v2_only_product" buying_mode: "wholesale" filters: channels: ["display"] @@ -555,6 +558,7 @@ phases: Return the seeded canonical_formats_custom_takeover product with a custom v2-only declaration and digest-pinned format_schema reference. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_canonical_formats_canonical_product_discovery_get_seeded_custom_v2_only_product" buying_mode: "wholesale" filters: channels: ["display"] @@ -620,6 +624,7 @@ phases: Return the seeded canonical_formats_experimental_display product with both v1 and v2 declarations and an experimental v2 marker. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_canonical_formats_canonical_product_discovery_get_seeded_experimental_product" buying_mode: "wholesale" filters: channels: ["display"] @@ -687,6 +692,7 @@ phases: flag the divergent declarations with a non-fatal FORMAT_DECLARATION_DIVERGENT advisory in `errors[]`. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_canonical_formats_canonical_product_discovery_get_seeded_divergent_product" buying_mode: "wholesale" filters: channels: ["display"] @@ -744,6 +750,7 @@ phases: validations can compare package format selectors against the product's canonical format_options[] declaration. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_canonical_formats_canonical_create_satisfaction_get_canonical_product_for_create_satisfaction" buying_mode: "wholesale" filters: channels: ["display"] diff --git a/static/compliance/source/protocols/media-buy/scenarios/clicks_buy_flow.yaml b/static/compliance/source/protocols/media-buy/scenarios/clicks_buy_flow.yaml index 6188133464..34cbe39510 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/clicks_buy_flow.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/clicks_buy_flow.yaml @@ -135,6 +135,7 @@ phases: Return at least one product whose metric_optimization capabilities include clicks as an optimization metric. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_clicks_buy_flow_setup_get_products_for_clicks" buying_mode: "brief" brief: "Display performance, US adults 25-54. Q2 flight ~$15K, optimizing for clicks with a target CPC." account: diff --git a/static/compliance/source/protocols/media-buy/scenarios/completed_views_buy_flow.yaml b/static/compliance/source/protocols/media-buy/scenarios/completed_views_buy_flow.yaml index 963fd9a6cd..54b8db1246 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/completed_views_buy_flow.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/completed_views_buy_flow.yaml @@ -144,6 +144,7 @@ phases: declare supported_view_durations. The buyer selects a duration from that list for the create_media_buy call below. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_completed_views_buy_flow_setup_get_products_for_cpcv" buying_mode: "brief" brief: "Online video / CTV, US adults 25-54. Q2 flight ~$30K, optimizing for completed views at a 6-second threshold with target CPCV." account: diff --git a/static/compliance/source/protocols/media-buy/scenarios/creative_fate_after_cancellation.yaml b/static/compliance/source/protocols/media-buy/scenarios/creative_fate_after_cancellation.yaml index 57287e65e6..8aa713e72a 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/creative_fate_after_cancellation.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/creative_fate_after_cancellation.yaml @@ -72,6 +72,7 @@ phases: expected: | Return at least one product with a pricing option and one canonical format option. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_creative_fate_after_cancellation_setup_get_products_brief" buying_mode: "brief" brief: "Display inventory on outdoor lifestyle content for creative-reuse testing." filters: diff --git a/static/compliance/source/protocols/media-buy/scenarios/delivery_reporting.yaml b/static/compliance/source/protocols/media-buy/scenarios/delivery_reporting.yaml index f9dfd3239d..2cf100f7bd 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/delivery_reporting.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/delivery_reporting.yaml @@ -124,6 +124,7 @@ phases: expected: | Return products matching the brief. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_delivery_reporting_setup_get_products_brief" buying_mode: "brief" brief: "Display and video inventory on outdoor lifestyle content. Q2 flight, $25K budget. Adults 25-54, US." account: diff --git a/static/compliance/source/protocols/media-buy/scenarios/demographic_targeting.yaml b/static/compliance/source/protocols/media-buy/scenarios/demographic_targeting.yaml index 919437d84a..ce21893fa7 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/demographic_targeting.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/demographic_targeting.yaml @@ -136,6 +136,7 @@ phases: doc_ref: "/media-buy/task-reference/get_products" stateful: true sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_demographic_targeting_discover_product_execution_get_demographic_products" buying_mode: "wholesale" fields: ["product_id", "pricing_options", "demographic_targeting", "signal_targeting_options"] account: diff --git a/static/compliance/source/protocols/media-buy/scenarios/dependency_impairment.yaml b/static/compliance/source/protocols/media-buy/scenarios/dependency_impairment.yaml index 10553e9728..61582f5c6c 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/dependency_impairment.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/dependency_impairment.yaml @@ -136,6 +136,7 @@ phases: expected: | Return at least one product with a pricing option and one canonical format option. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_dependency_impairment_setup_get_products_brief" buying_mode: "brief" brief: "Display inventory on outdoor lifestyle content for dependency-impairment testing." filters: diff --git a/static/compliance/source/protocols/media-buy/scenarios/dependency_impairment_cardinality.yaml b/static/compliance/source/protocols/media-buy/scenarios/dependency_impairment_cardinality.yaml index be43566b62..6ca1de63eb 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/dependency_impairment_cardinality.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/dependency_impairment_cardinality.yaml @@ -116,6 +116,7 @@ phases: expected: | Return at least one product with a pricing option and one canonical format option. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_dependency_impairment_cardinality_setup_get_products_brief" buying_mode: "brief" brief: "Display inventory on outdoor lifestyle content for dependency-impairment cardinality testing." filters: diff --git a/static/compliance/source/protocols/media-buy/scenarios/event_dedup_flow.yaml b/static/compliance/source/protocols/media-buy/scenarios/event_dedup_flow.yaml index f4da333766..f17044975d 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/event_dedup_flow.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/event_dedup_flow.yaml @@ -151,6 +151,7 @@ phases: Return at least one product whose conversion_tracking capabilities include multi_source_event_dedup. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_event_dedup_flow_setup_get_products_for_dedup" buying_mode: "brief" brief: "Performance display, US, adults 25-54. Target CPA $40 with pixel + CAPI conversion sources; same event_id from both must dedup." required_capabilities: diff --git a/static/compliance/source/protocols/media-buy/scenarios/frequency_cap_enforcement.yaml b/static/compliance/source/protocols/media-buy/scenarios/frequency_cap_enforcement.yaml index 00a5117e3e..6ca96d7748 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/frequency_cap_enforcement.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/frequency_cap_enforcement.yaml @@ -161,6 +161,7 @@ phases: Return at least one display product compatible with package-level frequency_cap targeting overlays. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_frequency_cap_enforcement_setup_get_products_for_frequency_cap" buying_mode: "brief" brief: "Display inventory, US adults 25-54, Q2 flight, ~$15K budget. Frequency-capped at 3 impressions per individual per day." account: diff --git a/static/compliance/source/protocols/media-buy/scenarios/get_products_async.yaml b/static/compliance/source/protocols/media-buy/scenarios/get_products_async.yaml index e74620e970..687912213a 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/get_products_async.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/get_products_async.yaml @@ -3,7 +3,7 @@ version: "1.0.0" introduced_in: "3.1" title: "Seller returns and completes async get_products discovery tasks" category: media_buy_seller -summary: "Forces curated get_products discovery into the submitted arm, verifies the task is visible through task APIs, completes it deterministically, and observes the terminal webhook result." +summary: "Forces curated get_products discovery into the submitted arm, verifies idempotent replay and conflict handling, confirms the task through task APIs, completes it deterministically, and observes the terminal webhook result." track: media_buy required_tools: - get_products @@ -73,6 +73,8 @@ phases: context_outputs: - name: forced_products_task_id path: "forced.task_id" + - name: submitted_products_idempotency_key + generate: uuid_v4 expected: | Return a successful directive: - success: true @@ -133,6 +135,7 @@ phases: - no products[] or proposals[] on the initial envelope - terminal products/proposals are delivered through task completion sample_request: + idempotency_key: "$context.submitted_products_idempotency_key" buying_mode: "brief" brief: "Curate display and video inventory for an Acme Outdoor fall trail campaign." pagination: @@ -168,6 +171,81 @@ phases: path: "result" description: "Terminal result is not returned on the submitted envelope" + - id: get_products_submitted_replay + title: "Replay the submitted discovery request" + narrative: | + Repeat the byte-equivalent request with the same idempotency key. The + seller must return the original submitted envelope and task_id rather + than consuming another controller directive or creating another task. + task: get_products + schema_ref: "media-buy/get-products-request.json" + response_schema_ref: "media-buy/get-products-async-response-submitted.json" + doc_ref: "/media-buy/task-reference/get_products" + comply_scenario: get_products_async + stateful: true + sample_request: + idempotency_key: "$context.submitted_products_idempotency_key" + buying_mode: "brief" + brief: "Curate display and video inventory for an Acme Outdoor fall trail campaign." + pagination: + max_results: 5 + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + push_notification_config: + url: "{{runner.webhook_url:get_products_submitted}}" + operation_id: "op_get_products_async_terminal" + context: + correlation_id: "get_products_async--get_products_submitted" + validations: + - check: response_schema + description: "Replay matches get-products-async-response-submitted.json" + - check: field_value + path: "task_id" + value: "$context.products_task_id" + description: "Replay returns the original task rather than creating a duplicate" + - check: field_value + path: "replayed" + value: true + description: "Replay is identified as an idempotency-cache response" + + - id: get_products_submitted_conflict + title: "Reject changed discovery input under the same key" + narrative: | + Reuse the submitted request's key with a different brief. This is not + a retry and must fail before a second task or webhook registration is + created. + task: get_products + schema_ref: "media-buy/get-products-request.json" + response_schema_ref: "media-buy/get-products-async-response-submitted.json" + doc_ref: "/media-buy/task-reference/get_products" + comply_scenario: idempotency_conflict + expect_error: true + negative_path: payload_well_formed + stateful: true + sample_request: + idempotency_key: "$context.submitted_products_idempotency_key" + buying_mode: "brief" + brief: "Curate only connected-TV inventory for a materially different campaign." + pagination: + max_results: 5 + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + push_notification_config: + url: "{{runner.webhook_url:get_products_submitted}}" + operation_id: "op_get_products_async_terminal" + context: + correlation_id: "get_products_async--get_products_submitted" + validations: + - check: error_code + allowed_values: ["IDEMPOTENCY_CONFLICT", "CONFLICT"] + description: "Changed payload under the submitted request key is rejected" + - id: list_products_task title: "Find submitted discovery task in list_tasks" task: list_tasks diff --git a/static/compliance/source/protocols/media-buy/scenarios/governance_approved.yaml b/static/compliance/source/protocols/media-buy/scenarios/governance_approved.yaml index 2a52e61e07..0ef4c6eef7 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/governance_approved.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/governance_approved.yaml @@ -200,6 +200,7 @@ phases: expected: | Return products matching the brief. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_governance_approved_buy_approved_get_products_brief" buying_mode: "brief" brief: "Display and video inventory on outdoor lifestyle content. Q2 flight, $25K budget. Adults 25-54, US." account: diff --git a/static/compliance/source/protocols/media-buy/scenarios/governance_conditions.yaml b/static/compliance/source/protocols/media-buy/scenarios/governance_conditions.yaml index 26efb2021f..9cfda9f7e6 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/governance_conditions.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/governance_conditions.yaml @@ -201,6 +201,7 @@ phases: expected: | Return the seeded CTV product and its USD pricing option. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_governance_conditions_buy_with_conditions_get_products_brief" buying_mode: "brief" brief: "CTV inventory on outdoor lifestyle content. $25K budget, adults 25-54, US." account: diff --git a/static/compliance/source/protocols/media-buy/scenarios/governance_denied.yaml b/static/compliance/source/protocols/media-buy/scenarios/governance_denied.yaml index 35d697f7a5..de867bdccc 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/governance_denied.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/governance_denied.yaml @@ -170,6 +170,7 @@ phases: expected: | Return products matching the brief. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_governance_denied_buy_denied_get_products_brief" buying_mode: "brief" brief: "Premium video and display on outdoor lifestyle. Q2 flight, $50K budget. Adults 25-54, US." account: diff --git a/static/compliance/source/protocols/media-buy/scenarios/governance_denied_recovery.yaml b/static/compliance/source/protocols/media-buy/scenarios/governance_denied_recovery.yaml index 9af80d3508..dc3603cf58 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/governance_denied_recovery.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/governance_denied_recovery.yaml @@ -170,6 +170,7 @@ phases: expected: | Return products matching the brief. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_governance_denied_recovery_buy_denied_get_products_brief" buying_mode: "brief" brief: "Display inventory on outdoor lifestyle content. Q2 flight." filters: diff --git a/static/compliance/source/protocols/media-buy/scenarios/inline_creatives_without_sync.yaml b/static/compliance/source/protocols/media-buy/scenarios/inline_creatives_without_sync.yaml index d345cd1ad6..5863de610a 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/inline_creatives_without_sync.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/inline_creatives_without_sync.yaml @@ -116,6 +116,7 @@ phases: Return at least one fixed-price product with a pricing option and a format_options[] declaration that can be used by the inline CreativeAsset. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_inline_creatives_without_sync_canonical_format_inline_path_get_products_canonical_format" buying_mode: "brief" brief: "Display inventory on outdoor lifestyle content. Q3 flight with buyer-supplied inline creative." filters: @@ -304,6 +305,7 @@ phases: Return at least one fixed-price product with a pricing option and a concrete format_id that can be used by the inline CreativeAsset. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_inline_creatives_without_sync_legacy_format_inline_path_get_products_legacy_format" buying_mode: "brief" brief: "Display inventory on outdoor lifestyle content. Q3 flight with buyer-supplied inline creative." filters: diff --git a/static/compliance/source/protocols/media-buy/scenarios/invalid_transitions.yaml b/static/compliance/source/protocols/media-buy/scenarios/invalid_transitions.yaml index 7686f03228..92ebfed3d5 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/invalid_transitions.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/invalid_transitions.yaml @@ -107,6 +107,7 @@ phases: expected: | Return at least one product with pricing. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_invalid_transitions_setup_get_products_brief" buying_mode: "brief" brief: "Display inventory on outdoor lifestyle content. Q3 flight." filters: diff --git a/static/compliance/source/protocols/media-buy/scenarios/inventory_list_no_match.yaml b/static/compliance/source/protocols/media-buy/scenarios/inventory_list_no_match.yaml index 814ac899e7..a383a73785 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/inventory_list_no_match.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/inventory_list_no_match.yaml @@ -58,6 +58,7 @@ phases: expected: | Return products the seller carries. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_inventory_list_no_match_discover_get_products_brief" buying_mode: "brief" brief: "Outdoor lifestyle programming. Q3 flight." filters: diff --git a/static/compliance/source/protocols/media-buy/scenarios/inventory_list_targeting.yaml b/static/compliance/source/protocols/media-buy/scenarios/inventory_list_targeting.yaml index b17256a7c9..1ce8254a64 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/inventory_list_targeting.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/inventory_list_targeting.yaml @@ -62,6 +62,7 @@ phases: collection_list references. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_inventory_list_targeting_discover_product_get_products_brief" buying_mode: "brief" brief: "Outdoor lifestyle programming. Q3 flight, $30K budget." filters: diff --git a/static/compliance/source/protocols/media-buy/scenarios/measurement_accountability.yaml b/static/compliance/source/protocols/media-buy/scenarios/measurement_accountability.yaml index 9318bdfcdb..fb7389ec25 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/measurement_accountability.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/measurement_accountability.yaml @@ -118,6 +118,7 @@ phases: includes "completed_views". Products that don't are silently excluded; the seller does NOT return an error for unmet metrics. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_measurement_accountability_discover_with_required_metrics_get_products_required_metrics" buying_mode: "brief" brief: "CTV inventory with completed-view reporting for a Q2 CPCV campaign." filters: diff --git a/static/compliance/source/protocols/media-buy/scenarios/measurement_terms_rejected.yaml b/static/compliance/source/protocols/media-buy/scenarios/measurement_terms_rejected.yaml index 0d1ee1164c..59f4029c01 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/measurement_terms_rejected.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/measurement_terms_rejected.yaml @@ -57,6 +57,7 @@ phases: Return products with pricing_options. At least one product should advertise measurement support in the response. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_measurement_terms_rejected_discover_products_get_products_brief" buying_mode: "brief" brief: "Premium video inventory with measurement guarantees. Q3 flight, $50K." filters: diff --git a/static/compliance/source/protocols/media-buy/scenarios/pending_creatives_to_start.yaml b/static/compliance/source/protocols/media-buy/scenarios/pending_creatives_to_start.yaml index 3b3c638748..cfa77f8b38 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/pending_creatives_to_start.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/pending_creatives_to_start.yaml @@ -65,6 +65,7 @@ phases: expected: | Return at least one product with canonical format_options and pricing options. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_pending_creatives_to_start_setup_get_products_brief" buying_mode: "brief" brief: "Display inventory on outdoor lifestyle content. Q3 flight." filters: diff --git a/static/compliance/source/protocols/media-buy/scenarios/per_creative_conversion_attribution.yaml b/static/compliance/source/protocols/media-buy/scenarios/per_creative_conversion_attribution.yaml index ba5012e991..be36479230 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/per_creative_conversion_attribution.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/per_creative_conversion_attribution.yaml @@ -162,6 +162,7 @@ phases: Return at least one product whose conversion_tracking capabilities include per-creative attribution. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_per_creative_conversion_attribution_setup_get_products_for_per_creative" buying_mode: "brief" brief: "Performance display, US, adults 25-54. Target CPA $35 on purchase events from acmeoutdoor.example. Two creative variants — need per-creative conversion breakdown in delivery reporting." required_capabilities: diff --git a/static/compliance/source/protocols/media-buy/scenarios/performance_buy_flow.yaml b/static/compliance/source/protocols/media-buy/scenarios/performance_buy_flow.yaml index 604740d341..a924c62ee5 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/performance_buy_flow.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/performance_buy_flow.yaml @@ -154,6 +154,7 @@ phases: Return at least one product whose conversion_tracking capabilities indicate event-kind goals are accepted. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_performance_buy_flow_setup_get_products_for_performance" buying_mode: "brief" brief: "Performance native + display, US, adults 25-54. Target CPA $35 on purchase events from acmeoutdoor.example." required_capabilities: diff --git a/static/compliance/source/protocols/media-buy/scenarios/performance_buy_flow_roas.yaml b/static/compliance/source/protocols/media-buy/scenarios/performance_buy_flow_roas.yaml index 8c6928a834..a032d11900 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/performance_buy_flow_roas.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/performance_buy_flow_roas.yaml @@ -168,6 +168,7 @@ phases: Return at least one product whose conversion_tracking capabilities accept per_ad_spend event-kind targets. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_performance_buy_flow_roas_setup_get_products_for_roas" buying_mode: "brief" brief: "Performance native + display, US, adults 25-54. Target ROAS 4.0 on purchase events with order_total from acmeoutdoor.example." required_capabilities: diff --git a/static/compliance/source/protocols/media-buy/scenarios/pricing_currency_filter.yaml b/static/compliance/source/protocols/media-buy/scenarios/pricing_currency_filter.yaml index 7d681682b5..95cb6c6ccc 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/pricing_currency_filter.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/pricing_currency_filter.yaml @@ -130,6 +130,7 @@ phases: satisfied in USD must be excluded. - Optional signal add-on pricing does not need to be pruned by this filter. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_pricing_currency_filter_discover_usd_products_get_products_usd_pricing" buying_mode: "wholesale" filters: pricing_currencies: ["USD"] diff --git a/static/compliance/source/protocols/media-buy/scenarios/product_signal_targeting.yaml b/static/compliance/source/protocols/media-buy/scenarios/product_signal_targeting.yaml index 74b65794f3..1abf203baa 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/product_signal_targeting.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/product_signal_targeting.yaml @@ -111,6 +111,7 @@ phases: signal_targeting_rules. Inline signal_targeting_options may be omitted because this scenario uses get_signals as the selectable signal feed. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_product_signal_targeting_discover_wholesale_product_get_products_wholesale" buying_mode: "wholesale" filters: channels: ["display"] diff --git a/static/compliance/source/protocols/media-buy/scenarios/proposal_finalize.yaml b/static/compliance/source/protocols/media-buy/scenarios/proposal_finalize.yaml index 1b49b9c856..09d3a5891b 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/proposal_finalize.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/proposal_finalize.yaml @@ -95,6 +95,7 @@ phases: - proposals: curated media plans with proposal_id, budget_allocations, rationale sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_proposal_finalize_brief_with_proposals_get_products_brief" buying_mode: "brief" brief: "Premium video and display across outdoor lifestyle and sports. Q2 flight, $50K budget. Adults 25-54, US and Canada." account: @@ -142,6 +143,7 @@ phases: - Updated budget allocations, product selections, and forecasts sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_proposal_finalize_refine_proposal_get_products_refine" buying_mode: "refine" refine: - scope: "proposal" @@ -154,6 +156,10 @@ phases: domain: "acmeoutdoor.example" operator: "pinnacle-agency.example" + context_outputs: + - name: finalize_idempotency_key + generate: uuid_v4 + validations: - check: response_schema description: "Response matches get-products-response.json schema" @@ -185,6 +191,7 @@ phases: - field: refine[0].proposal_id sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_proposal_finalize_unknown_proposal_finalize_get_products_finalize_unknown_proposal" buying_mode: "refine" refine: - scope: "proposal" @@ -236,6 +243,7 @@ phases: - The proposal is ready to execute via create_media_buy sample_request: + idempotency_key: "$context.finalize_idempotency_key" buying_mode: "refine" refine: - scope: "proposal" @@ -249,6 +257,8 @@ phases: context_outputs: - path: "proposals[0].insertion_order.io_id" key: "io_id" + - path: "proposals[0].expires_at" + key: "finalized_proposal_expires_at" validations: - check: response_schema @@ -257,6 +267,45 @@ phases: path: "proposals" description: "Response contains the finalized proposal" + - id: get_products_finalize_replay + title: "Replay proposal finalization without creating another hold" + narrative: | + Repeat the exact finalize request with the same idempotency key. The + cached proposal must preserve both the original inventory-hold expiry + and insertion order rather than committing the proposal again. + task: get_products + schema_ref: "media-buy/get-products-request.json" + response_schema_ref: "media-buy/get-products-response.json" + doc_ref: "/media-buy/task-reference/get_products" + comply_scenario: full_sales_flow + stateful: true + sample_request: + idempotency_key: "$context.finalize_idempotency_key" + buying_mode: "refine" + refine: + - scope: "proposal" + proposal_id: "$context.proposal_id" + action: "finalize" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + validations: + - check: response_schema + description: "Replay matches get-products-response.json schema" + - check: field_value + path: "replayed" + value: true + description: "Finalize retry is served from the idempotency cache" + - check: field_value + path: "proposals[0].insertion_order.io_id" + value: "$context.io_id" + description: "Replay preserves the original insertion order" + - check: field_value + path: "proposals[0].expires_at" + value: "$context.finalized_proposal_expires_at" + description: "Replay preserves the original inventory-hold expiry" + - id: accept_proposal title: "Execute the committed proposal" narrative: | @@ -330,6 +379,7 @@ phases: - field: refine[0].proposal_id sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_proposal_finalize_unknown_proposal_references_get_products_refine_unknown_proposal" buying_mode: "refine" refine: - scope: "proposal" diff --git a/static/compliance/source/protocols/media-buy/scenarios/proposal_finalize_asap_timing.yaml b/static/compliance/source/protocols/media-buy/scenarios/proposal_finalize_asap_timing.yaml index cdef780fac..f49b40416d 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/proposal_finalize_asap_timing.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/proposal_finalize_asap_timing.yaml @@ -95,6 +95,7 @@ phases: - proposals: curated media plans with proposal_id, budget_allocations, rationale sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_proposal_finalize_asap_timing_brief_with_proposals_get_products_brief" buying_mode: "brief" brief: "Premium video and display across outdoor lifestyle and sports. Q2 flight, $50K budget. Adults 25-54, US and Canada." account: @@ -137,6 +138,7 @@ phases: Return the refined proposal reflecting the requested changes. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_proposal_finalize_asap_timing_refine_proposal_get_products_refine" buying_mode: "refine" refine: - scope: "proposal" @@ -179,6 +181,7 @@ phases: - The proposal is ready to execute via create_media_buy sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_proposal_finalize_asap_timing_finalize_proposal_get_products_finalize" buying_mode: "refine" refine: - scope: "proposal" diff --git a/static/compliance/source/protocols/media-buy/scenarios/proposal_not_found_errors.yaml b/static/compliance/source/protocols/media-buy/scenarios/proposal_not_found_errors.yaml index ae8873bd66..a6c06ca71b 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/proposal_not_found_errors.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/proposal_not_found_errors.yaml @@ -112,6 +112,7 @@ phases: - context echoed unchanged sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_proposal_not_found_errors_unknown_proposal_refine_get_products_refine_unknown_proposal" buying_mode: "refine" refine: - scope: "proposal" diff --git a/static/compliance/source/protocols/media-buy/scenarios/provenance_audit_observation.yaml b/static/compliance/source/protocols/media-buy/scenarios/provenance_audit_observation.yaml index 6eebb861a9..2b9ca57d4a 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/provenance_audit_observation.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/provenance_audit_observation.yaml @@ -87,6 +87,7 @@ phases: populated. Buyer reads the agent_url for use on its verify_agent reference in the next phase. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_provenance_audit_observation_discover_verifier_get_products_with_accepted_verifiers" buying_mode: "brief" # Brief intentionally surfaces "Provenance Audit Observation" so # brief-mode keyword scoring places the seeded fixture product at diff --git a/static/compliance/source/protocols/media-buy/scenarios/provenance_enforcement.yaml b/static/compliance/source/protocols/media-buy/scenarios/provenance_enforcement.yaml index f80c1d97f3..e1722ca813 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/provenance_enforcement.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/provenance_enforcement.yaml @@ -102,6 +102,7 @@ phases: so the buyer's orchestrator can read both the structural requirement and the seller's verifier allowlist before submission. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_provenance_enforcement_discover_requirement_get_products_with_disclosure_policy" buying_mode: "brief" # Brief intentionally surfaces "Provenance Enforcement" so brief-mode # keyword scoring places the seeded fixture product (which carries diff --git a/static/compliance/source/protocols/media-buy/scenarios/provenance_truth_of_claim.yaml b/static/compliance/source/protocols/media-buy/scenarios/provenance_truth_of_claim.yaml index 97cc678e47..d498c7945f 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/provenance_truth_of_claim.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/provenance_truth_of_claim.yaml @@ -102,6 +102,7 @@ phases: populated. Buyer reads the agent_url for use on its verify_agent reference in the next phase. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_provenance_truth_of_claim_discover_verifier_get_products_with_accepted_verifiers" buying_mode: "brief" brief: "Provenance Truth-of-Claim display inventory — verifier-driven AI detection on accepted verifier allowlist" brand: diff --git a/static/compliance/source/protocols/media-buy/scenarios/reach_buy_flow.yaml b/static/compliance/source/protocols/media-buy/scenarios/reach_buy_flow.yaml index 5cb5663068..6d2c0cd57e 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/reach_buy_flow.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/reach_buy_flow.yaml @@ -148,6 +148,7 @@ phases: supported_reach_units. The buyer selects a unit from that list for the create_media_buy call below. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_reach_buy_flow_setup_get_products_for_reach" buying_mode: "brief" brief: "CTV video, US adults 25-54. Q2 brand flight ~$50K, optimizing for unique household reach with a target frequency cap." account: diff --git a/static/compliance/source/protocols/media-buy/scenarios/refine_finalize_exclusivity.yaml b/static/compliance/source/protocols/media-buy/scenarios/refine_finalize_exclusivity.yaml index 72912c46a5..52484c10d4 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/refine_finalize_exclusivity.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/refine_finalize_exclusivity.yaml @@ -105,6 +105,7 @@ phases: Return at least one proposal with a proposal_id. proposal_id_1 is captured here for the mandatory mixed-finalize and non-proposal-finalize error probes. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_refine_finalize_exclusivity_setup_get_products_brief" buying_mode: "brief" brief: "Premium video and display across outdoor lifestyle and sports. Q2 flight, $50K budget. Adults 25-54, US and Canada." account: @@ -147,6 +148,7 @@ phases: (tolerated — phase is optional), proposal_id_2 stays unset, and the multi-finalize phases skip gracefully. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_refine_finalize_exclusivity_setup_second_proposal_get_products_brief_second" buying_mode: "brief" brief: "Premium video and display across outdoor lifestyle and sports. Q2 flight, $50K budget. Adults 25-54, US and Canada." account: @@ -190,6 +192,7 @@ phases: the violation is conformant) No proposal state is changed; the finalize entry does not commit. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_refine_finalize_exclusivity_mixed_finalize_rejected_get_products_mixed_finalize" buying_mode: "refine" refine: - scope: "proposal" @@ -240,6 +243,7 @@ phases: product-scoped entries; the seller must not silently ignore the field or treat it as action: "include". sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_refine_finalize_exclusivity_non_proposal_finalize_rejected_get_products_product_finalize" buying_mode: "refine" refine: - scope: "product" @@ -284,6 +288,7 @@ phases: refinements as applied. Neither proposal is partially committed — the seller has run pre-commit validation across all named proposals before returning success. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_refine_finalize_exclusivity_multi_finalize_atomic_path_get_products_multi_finalize_atomic" buying_mode: "refine" refine: - scope: "proposal" @@ -375,6 +380,7 @@ phases: (acceptable fallback). No proposal is committed. Buyers receiving MULTI_FINALIZE_UNSUPPORTED should sequence individual finalize calls. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_refine_finalize_exclusivity_multi_finalize_unsupported_path_get_products_multi_finalize_unsupported" buying_mode: "refine" refine: - scope: "proposal" diff --git a/static/compliance/source/protocols/media-buy/scenarios/refine_products.yaml b/static/compliance/source/protocols/media-buy/scenarios/refine_products.yaml index f47c311c2c..c156b5df14 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/refine_products.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/refine_products.yaml @@ -86,6 +86,7 @@ phases: delivery_type, pricing_models, and forecast. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_refine_products_brief_get_products_brief" buying_mode: "brief" brief: "Premium video and display on sports and outdoor lifestyle. Q2 flight, $50K budget. Adults 25-54, US and Canada." account: @@ -128,6 +129,7 @@ phases: - Update pricing and forecasts to reflect the changes sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_refine_products_refine_get_products_refine" buying_mode: "refine" refine: - scope: "request" diff --git a/static/compliance/source/protocols/media-buy/scenarios/vendor_metric_accountability.yaml b/static/compliance/source/protocols/media-buy/scenarios/vendor_metric_accountability.yaml index 070c89a7f4..645622f8dd 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/vendor_metric_accountability.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/vendor_metric_accountability.yaml @@ -147,6 +147,7 @@ phases: that don't declare this vendor are silently excluded — the seller does NOT return an error for unmet vendor metric requirements. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_vendor_metric_accountability_discover_with_required_vendor_metrics_get_products_required_vendor_metrics" buying_mode: "brief" brief: "Display inventory with attention measurement from attentionvendor." filters: diff --git a/static/compliance/source/protocols/media-buy/scenarios/vendor_metric_optimization_flow.yaml b/static/compliance/source/protocols/media-buy/scenarios/vendor_metric_optimization_flow.yaml index 79b99732bf..8828523e54 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/vendor_metric_optimization_flow.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/vendor_metric_optimization_flow.yaml @@ -183,6 +183,7 @@ phases: includes an entry for attentionvendor.example / attention_score. The supported_targets on that entry includes threshold_rate. sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_seller_vendor_metric_optimization_flow_discover_vendor_metric_optimization_get_products_vendor_metric_opt" buying_mode: "brief" brief: "Display inventory with attention-score optimization from attentionvendor." filters: diff --git a/static/compliance/source/protocols/media-buy/state-machine.yaml b/static/compliance/source/protocols/media-buy/state-machine.yaml index b7511f3205..9116bec555 100644 --- a/static/compliance/source/protocols/media-buy/state-machine.yaml +++ b/static/compliance/source/protocols/media-buy/state-machine.yaml @@ -119,6 +119,7 @@ phases: - pricing_options with pricing_option_id sample_request: + idempotency_key: "$generate:uuid_v4#media_buy_state_machine_setup_discover_products" buying_mode: "brief" brief: "Products for state machine testing" brand: diff --git a/static/compliance/source/specialisms/creative-generative/generative-seller.yaml b/static/compliance/source/specialisms/creative-generative/generative-seller.yaml index d1c47c9c25..27efcfba2d 100644 --- a/static/compliance/source/specialisms/creative-generative/generative-seller.yaml +++ b/static/compliance/source/specialisms/creative-generative/generative-seller.yaml @@ -273,6 +273,7 @@ phases: - creative format_options: canonical formats this product accepts, including generative formats sample_request: + idempotency_key: "$generate:uuid_v4#creative_generative_seller_product_discovery_get_products_brief" buying_mode: "brief" brief: "Premium display and video inventory on outdoor lifestyle content. Q2 flight, $50K budget. Adults 25-54, US. We want your platform to generate the creatives from our brand brief." account: diff --git a/static/compliance/source/specialisms/sales-broadcast-tv/index.yaml b/static/compliance/source/specialisms/sales-broadcast-tv/index.yaml index ffbeaea1bc..dfd7522b3b 100644 --- a/static/compliance/source/specialisms/sales-broadcast-tv/index.yaml +++ b/static/compliance/source/specialisms/sales-broadcast-tv/index.yaml @@ -192,6 +192,7 @@ phases: - c7: live + 7 days DVR playback, available ~8 days after air sample_request: + idempotency_key: "$generate:uuid_v4#sales_broadcast_tv_product_discovery_get_products_brief" buying_mode: "brief" brief: "Primetime and late fringe broadcast spots for an automotive EV launch. Q4 flight, $400K budget. Adults 25-54, national footprint. Need :30 and :15 spot lengths." account: diff --git a/static/compliance/source/specialisms/sales-catalog-driven/index.yaml b/static/compliance/source/specialisms/sales-catalog-driven/index.yaml index 75010383bf..04d434df38 100644 --- a/static/compliance/source/specialisms/sales-catalog-driven/index.yaml +++ b/static/compliance/source/specialisms/sales-catalog-driven/index.yaml @@ -487,6 +487,7 @@ phases: catalog compatibility in their format requirements. sample_request: + idempotency_key: "$generate:uuid_v4#sales_catalog_driven_create_buy_get_products" buying_mode: "brief" brief: "Dynamic product ads for a high-end steakhouse. Geo-targeted to 10 miles around Amsterdam location. Drive reservations and foot traffic." account: diff --git a/static/compliance/source/specialisms/sales-guaranteed/index.yaml b/static/compliance/source/specialisms/sales-guaranteed/index.yaml index 1262ea3f49..c72941096f 100644 --- a/static/compliance/source/specialisms/sales-guaranteed/index.yaml +++ b/static/compliance/source/specialisms/sales-guaranteed/index.yaml @@ -332,6 +332,7 @@ phases: - minimum_spend or commitment terms if applicable sample_request: + idempotency_key: "$generate:uuid_v4#sales_guaranteed_product_discovery_get_products_brief" buying_mode: "brief" brief: "Guaranteed premium video on sports and outdoor lifestyle publishers. Q2 flight, $50K budget. Adults 25-54, US only. Need completion rate SLA." account: diff --git a/static/compliance/source/specialisms/sales-non-guaranteed/index.yaml b/static/compliance/source/specialisms/sales-non-guaranteed/index.yaml index f042f1bd1d..fb72f44974 100644 --- a/static/compliance/source/specialisms/sales-non-guaranteed/index.yaml +++ b/static/compliance/source/specialisms/sales-non-guaranteed/index.yaml @@ -184,6 +184,7 @@ phases: - targeting: available audiences and contexts sample_request: + idempotency_key: "$generate:uuid_v4#sales_non_guaranteed_product_discovery_get_products_brief" buying_mode: "brief" brief: "Display and video inventory across sports and outdoor lifestyle sites. Q2 flight, $25K budget. Adults 25-54, US. Auction-based, looking for competitive CPMs." filters: diff --git a/static/compliance/source/specialisms/sales-proposal-mode/index.yaml b/static/compliance/source/specialisms/sales-proposal-mode/index.yaml index b1e0687062..f8294ef2db 100644 --- a/static/compliance/source/specialisms/sales-proposal-mode/index.yaml +++ b/static/compliance/source/specialisms/sales-proposal-mode/index.yaml @@ -199,6 +199,7 @@ phases: - forecast: aggregate impressions, reach, frequency sample_request: + idempotency_key: "$generate:uuid_v4#sales_proposal_mode_brief_with_proposals_get_products_brief" buying_mode: "brief" brief: "Premium video and display across outdoor lifestyle and sports. Q2 flight, $50K total budget. Adults 25-54, US and Canada. Looking for a balanced plan across CTV, online video, and display." account: @@ -266,6 +267,7 @@ phases: - products: updated product set if products were swapped sample_request: + idempotency_key: "$generate:uuid_v4#sales_proposal_mode_review_refine_get_products_refine" buying_mode: "refine" refine: - scope: "proposal" @@ -332,6 +334,7 @@ phases: - The proposal is ready to execute via create_media_buy sample_request: + idempotency_key: "$generate:uuid_v4#sales_proposal_mode_finalize_proposal_get_products_finalize" buying_mode: "refine" refine: - scope: "proposal" diff --git a/static/compliance/source/universal/error-compliance.yaml b/static/compliance/source/universal/error-compliance.yaml index 862678a36a..5fd0fbb9e2 100644 --- a/static/compliance/source/universal/error-compliance.yaml +++ b/static/compliance/source/universal/error-compliance.yaml @@ -208,6 +208,7 @@ phases: structured error requesting the missing brief. Both are valid. sample_request: + idempotency_key: "$generate:uuid_v4#error_compliance_error_responses_missing_fields" account: brand: domain: "acmeoutdoor.example" @@ -360,6 +361,7 @@ phases: - message referencing the buyer's requested version and the seller's supported set sample_request: + idempotency_key: "$generate:uuid_v4#error_compliance_version_negotiation_unsupported_major_version" adcp_major_version: 99 buying_mode: "brief" brief: "Version negotiation probe" @@ -414,6 +416,7 @@ phases: - message referencing the buyer's requested release and the seller's supported set sample_request: + idempotency_key: "$generate:uuid_v4#error_compliance_version_negotiation_unsupported_release_version" adcp_version: "99.0" buying_mode: "brief" brief: "Release-precision version negotiation probe" @@ -452,6 +455,7 @@ phases: Return products normally — adcp_major_version: 3 is a supported version. sample_request: + idempotency_key: "$generate:uuid_v4#error_compliance_version_negotiation_supported_major_version" adcp_major_version: 3 buying_mode: "brief" brief: "Display advertising for outdoor lifestyle campaign" diff --git a/static/compliance/source/universal/get-products-pagination-integrity.yaml b/static/compliance/source/universal/get-products-pagination-integrity.yaml index 5bb88c6f97..b22d311f14 100644 --- a/static/compliance/source/universal/get-products-pagination-integrity.yaml +++ b/static/compliance/source/universal/get-products-pagination-integrity.yaml @@ -165,6 +165,7 @@ phases: - pagination.cursor present sample_request: + idempotency_key: "$generate:uuid_v4#get_products_pagination_integrity_wholesale_pagination_walk_wholesale_first_page" buying_mode: "wholesale" account: brand: @@ -236,6 +237,7 @@ phases: - pagination.cursor absent or null sample_request: + idempotency_key: "$generate:uuid_v4#get_products_pagination_integrity_wholesale_pagination_walk_wholesale_terminal_page" buying_mode: "wholesale" account: brand: diff --git a/static/compliance/source/universal/read-tool-idempotency.yaml b/static/compliance/source/universal/read-tool-idempotency.yaml index 4968ff30b1..d51ab0f714 100644 --- a/static/compliance/source/universal/read-tool-idempotency.yaml +++ b/static/compliance/source/universal/read-tool-idempotency.yaml @@ -94,9 +94,10 @@ phases: - id: get_products_with_idempotency_key title: "Product discovery read accepts idempotency_key" narrative: | - `get_products` is the canonical media-buy read. Sellers that expose - it must accept the 3.1 every-request envelope even though the field is - not part of the task-specific product-discovery payload. + This brief-mode `get_products` probe resolves synchronously as a read, + but the tool is polymorphic: other calls may create a task or finalize + a proposal. Its request schema therefore declares `idempotency_key`, + and sellers must apply the same replay contract to this read result. task: get_products requires_tool: get_products schema_ref: "media-buy/get-products-request.json" diff --git a/static/compliance/source/universal/schema-validation.yaml b/static/compliance/source/universal/schema-validation.yaml index acd993bc87..dd1a7bfea6 100644 --- a/static/compliance/source/universal/schema-validation.yaml +++ b/static/compliance/source/universal/schema-validation.yaml @@ -131,6 +131,7 @@ phases: what other fields are present. sample_request: + idempotency_key: "$generate:uuid_v4#schema_validation_schema_compliance_get_products_schema" buying_mode: "brief" brief: "Return a small representative sample of available advertising products" pagination: @@ -180,6 +181,7 @@ phases: - pricing_model: CPM, CPC, flat_rate, etc. sample_request: + idempotency_key: "$generate:uuid_v4#schema_validation_schema_compliance_pricing_options_present" buying_mode: "brief" brief: "Premium display inventory with pricing details" pagination: @@ -250,6 +252,7 @@ phases: These canonical selectors must be accepted unchanged in subsequent requests. sample_request: + idempotency_key: "$generate:uuid_v4#schema_validation_format_id_reconciliation_get_products_for_formats" buying_mode: "brief" brief: "Return one representative product with creative format requirements" pagination: diff --git a/static/compliance/source/universal/stale-response-advisory.yaml b/static/compliance/source/universal/stale-response-advisory.yaml index 9d48ae858c..5a580e72d1 100644 --- a/static/compliance/source/universal/stale-response-advisory.yaml +++ b/static/compliance/source/universal/stale-response-advisory.yaml @@ -178,6 +178,7 @@ phases: - Response payload non-empty (products[] with at least one entry) sample_request: + idempotency_key: "$generate:uuid_v4#stale_response_advisory_stale_response_forcing_stale_response_wire_placement" buying_mode: "brief" brief: "Display advertising" account: @@ -275,6 +276,7 @@ phases: actively serving from a stale cache entry past its freshness target. sample_request: + idempotency_key: "$generate:uuid_v4#stale_response_advisory_non_emission_guard_no_stale_on_healthy_upstream" buying_mode: "brief" brief: "Display advertising for outdoor lifestyle campaign" account: diff --git a/static/compliance/source/universal/webhook-emission.yaml b/static/compliance/source/universal/webhook-emission.yaml index 039fcb701b..050b00e2cb 100644 --- a/static/compliance/source/universal/webhook-emission.yaml +++ b/static/compliance/source/universal/webhook-emission.yaml @@ -430,6 +430,7 @@ phases: stateful: false contributes: true sample_request: + idempotency_key: "$generate:uuid_v4#webhook_emission_synchronous_completion_success_path_sync_get_products_with_webhook_config_success" buying_mode: "wholesale" account: brand: @@ -486,6 +487,7 @@ phases: stateful: false contributes: true sample_request: + idempotency_key: "$generate:uuid_v4#webhook_emission_synchronous_completion_rejection_path_sync_get_products_with_webhook_config_reject" buying_mode: "wholesale" account: brand: diff --git a/static/compliance/source/universal/wholesale-feed-products.yaml b/static/compliance/source/universal/wholesale-feed-products.yaml index 06b12843cf..a3a80aa064 100644 --- a/static/compliance/source/universal/wholesale-feed-products.yaml +++ b/static/compliance/source/universal/wholesale-feed-products.yaml @@ -55,6 +55,7 @@ phases: expected: | Return product rows with wholesale feed metadata. sample_request: + idempotency_key: "$generate:uuid_v4#wholesale_feed_products_conditional_fetch_bootstrap_products" buying_mode: "wholesale" account: brand: @@ -93,6 +94,7 @@ phases: expected: | Return unchanged without resending product rows when the feed token matches. sample_request: + idempotency_key: "$generate:uuid_v4#wholesale_feed_products_conditional_fetch_unchanged_probe" buying_mode: "wholesale" account: brand: @@ -137,6 +139,7 @@ phases: expected: | Reject if_pricing_version when if_wholesale_feed_version is absent. sample_request: + idempotency_key: "$generate:uuid_v4#wholesale_feed_products_conditional_fetch_standalone_pricing_token_rejected" buying_mode: "wholesale" account: brand: diff --git a/static/schemas/source/manifest.schema.json b/static/schemas/source/manifest.schema.json index 81d3117344..317d2ba90c 100644 --- a/static/schemas/source/manifest.schema.json +++ b/static/schemas/source/manifest.schema.json @@ -52,7 +52,7 @@ }, "mutating": { "type": "boolean", - "description": "True if invoking this tool with identical inputs more than once is unsafe — i.e., the tool changes server-side state. Mutating tools MUST declare an idempotency_key on the request schema (or carry an explicit `naturally idempotent` exemption marker in the schema's description). Read-only tools (verbs `get-`, `list-`, `check-`, `validate-`, `preview-`, `search-`) are safe to retry without an idempotency key." + "description": "True if invoking this tool can change server-side state. Mutating tools MUST declare an idempotency_key on the request schema (or carry an explicit `naturally idempotent` exemption marker in the schema's description). Verb prefixes are not authoritative: polymorphic tools such as get_products are mutating because some valid request arms create asynchronous tasks or commit state, even though other arms complete as reads." }, "request_schema": { "type": "string", diff --git a/static/schemas/source/media-buy/get-products-request.json b/static/schemas/source/media-buy/get-products-request.json index eddd23f500..b3a4a06c75 100644 --- a/static/schemas/source/media-buy/get-products-request.json +++ b/static/schemas/source/media-buy/get-products-request.json @@ -36,7 +36,15 @@ } } ], + "x-mutates-state": true, "properties": { + "idempotency_key": { + "type": "string", + "description": "Client-generated unique key for this request. get_products is polymorphic: a call may complete as a read, create an asynchronous task, or finalize a proposal and commit an inventory hold. Reusing the same key with the same canonical payload returns the original response without repeating those effects. MUST be unique per (seller, request) pair to prevent cross-seller correlation. Use a fresh UUID v4 for each new request and reuse it only for retries.", + "minLength": 16, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{16,255}$" + }, "buying_mode": { "type": "string", "enum": [ @@ -269,6 +277,7 @@ } }, "required": [ + "idempotency_key", "buying_mode" ], "dependencies": { diff --git a/tests/composed-schema-validation.test.cjs b/tests/composed-schema-validation.test.cjs index c4d3fd5560..5941c1e346 100644 --- a/tests/composed-schema-validation.test.cjs +++ b/tests/composed-schema-validation.test.cjs @@ -3056,6 +3056,7 @@ async function runTests() { await testSchemaValidation( '/schemas/media-buy/get-products-request.json', { + idempotency_key: '550e8400-e29b-41d4-a716-446655440005', buying_mode: 'wholesale', filters: { signal_targeting: [ diff --git a/tests/example-validation-simple.test.cjs b/tests/example-validation-simple.test.cjs index d09ed67932..8d179e43bd 100644 --- a/tests/example-validation-simple.test.cjs +++ b/tests/example-validation-simple.test.cjs @@ -396,6 +396,7 @@ async function runTests() { // Test request/response examples await validateExample( { + "idempotency_key": "550e8400-e29b-41d4-a716-446655440001", "buying_mode": "brief", "account": { "brand": { "domain": "nikeinc.com", "brand_id": "nike" }, "operator": "nikeinc.com" }, "brand": { @@ -1454,6 +1455,7 @@ async function runTests() { // proves the schema rejects it with a migration-diagnosable error. await expectInvalid( { + "idempotency_key": "550e8400-e29b-41d4-a716-446655440002", "buying_mode": "refine", "refine": [{ "scope": "product", "id": "prod_video_premium", "action": "include" }] }, @@ -1464,6 +1466,7 @@ async function runTests() { await expectInvalid( { + "idempotency_key": "550e8400-e29b-41d4-a716-446655440003", "buying_mode": "refine", "refine": [{ "scope": "proposal", "id": "prop_balanced_v1", "action": "finalize" }] }, @@ -1475,6 +1478,7 @@ async function runTests() { // Happy path — optional `action` defaults to include server-side; schema must accept the minimal shape. await validateExample( { + "idempotency_key": "550e8400-e29b-41d4-a716-446655440004", "buying_mode": "refine", "refine": [ { "scope": "product", "product_id": "prod_video_premium" }, diff --git a/tests/example-validation.test.cjs b/tests/example-validation.test.cjs index ad537052cb..29edaa410d 100644 --- a/tests/example-validation.test.cjs +++ b/tests/example-validation.test.cjs @@ -202,6 +202,7 @@ const exampleData = { // Request/Response examples getProductsRequest: { + "idempotency_key": "550e8400-e29b-41d4-a716-446655440000", "buying_mode": "brief", "brief": "Nike Air Max 2024 - Premium video inventory for sports fans" }, diff --git a/tests/lint-storyboard-contradictions.test.cjs b/tests/lint-storyboard-contradictions.test.cjs index ec889c0031..e8221d0727 100644 --- a/tests/lint-storyboard-contradictions.test.cjs +++ b/tests/lint-storyboard-contradictions.test.cjs @@ -68,6 +68,7 @@ test('schema-derived set covers known mutating tasks', () => { 'sync_audiences', 'comply_test_controller', 'si_terminate_session', + 'get_products', ]) { assert.ok(derived.has(task), `expected ${task} in schema-derived mutating set`); } @@ -81,7 +82,6 @@ test('schema-derived set does not over-match read-only tasks', () => { // in a false-positive at build time. const derived = loadMutatingTasksFromSchemas(SCHEMAS_DIR); for (const task of [ - 'get_products', 'get_signals', 'list_creative_formats', 'get_adcp_capabilities', From 5d96da342a3284568d92481ccdfcea6b2c41cf11 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 30 Jul 2026 18:31:24 -0400 Subject: [PATCH 02/49] fix(ci): preserve 3.0 get_products compatibility --- server/src/training-agent/task-handlers.ts | 61 ++++++++++++++++-- .../tenants/tenant-smoke.test.ts | 33 ++++++++++ .../unit/training-agent-idempotency.test.ts | 63 +++++++++++++++++++ 3 files changed, 151 insertions(+), 6 deletions(-) diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 67833a89aa..91318265b6 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -1418,6 +1418,7 @@ import { validateKeyFormat, scopedPrincipal, getIdempotencyStore, + payloadHash, REPLAY_TTL_SECONDS, } from './idempotency.js'; import { maybeEmitCompletionWebhook } from './webhooks.js'; @@ -9711,6 +9712,42 @@ function validateIdempotencyProtectedInput( return undefined; } +function applyThreeZeroGetProductsIdempotencyCompatibility( + toolName: string, + args: Record, + scopedCallerPrincipal: string, + compatibilityEnabled: boolean, +): Record { + if ( + toolName !== 'get_products' + || !compatibilityEnabled + || args.idempotency_key !== undefined + ) { + return args; + } + + // Frozen 3.0 get_products examples predate the now-required key. Give only + // that legacy wire shape a deterministic internal key so exact retries still + // converge through the normal schema/cache/task/finalize path. Context and + // version negotiation are envelope concerns, not logical request identity. + const { + context: _context, + context_id: _contextId, + adcp_version: _adcpVersion, + adcp_major_version: _adcpMajorVersion, + ...canonicalRequest + } = args; + const fingerprint = createHash('sha256') + .update(scopedCallerPrincipal) + .update('\0') + .update(payloadHash(canonicalRequest)) + .digest('hex'); + return { + ...canonicalRequest, + idempotency_key: `compat30:${fingerprint}`, + }; +} + /** * Execute a training agent tool in-process (no HTTP round-trip). * Used by Addie's adcp-tools during certification demos. @@ -9733,8 +9770,8 @@ async function executeTrainingAgentToolInContext( // Keeping it out of the cached inner response prevents a replay from // returning the original caller's correlation data. const rawArgs = args as unknown as Record; - const { context: callerContext, ...handlerArgs } = rawArgs; - const versionResolution = resolveServedAdcpVersionForTool(toolName, handlerArgs); + const { context: callerContext, ...initialHandlerArgs } = rawArgs; + const versionResolution = resolveServedAdcpVersionForTool(toolName, initialHandlerArgs); if (!versionResolution.ok) { return { success: false, error: versionResolution.message }; } @@ -9749,8 +9786,14 @@ async function executeTrainingAgentToolInContext( return { success: false, error: `Unknown tool: ${toolName}` }; } const authPrincipal = ctx.principal ?? ctx.userId ?? 'anonymous'; - const accountScope = deriveAccountScope(handlerArgs); + const accountScope = deriveAccountScope(initialHandlerArgs); const principal = scopedPrincipal(authPrincipal, accountScope); + const handlerArgs = applyThreeZeroGetProductsIdempotencyCompatibility( + toolName, + initialHandlerArgs, + principal, + ctx.storyboardCompat?.version === '3.0' || initialHandlerArgs.adcp_version === '3.0', + ); const idempotencyKey = handlerArgs.idempotency_key; let claim: { payloadHash: string } | undefined; @@ -9872,8 +9915,8 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { // Extract and strip context before passing args to handlers (AdCP requirement: // echo caller's context object back unchanged in every response). const rawArgs = (args as Record | undefined) ?? {}; - const { context: callerContext, ...handlerArgs } = rawArgs; - const versionResolution = resolveServedAdcpVersionForTool(name, handlerArgs); + const { context: callerContext, ...initialHandlerArgs } = rawArgs; + const versionResolution = resolveServedAdcpVersionForTool(name, initialHandlerArgs); const handler = HANDLER_MAP[name]; @@ -9922,8 +9965,14 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { // callers can already enumerate their own account's keys — so the // scoping adds no useful probing surface while closing the cross-caller // leak. - const accountScope = deriveAccountScope(handlerArgs); + const accountScope = deriveAccountScope(initialHandlerArgs); const idempotencyPrincipal = scopedPrincipal(authPrincipal, accountScope); + const handlerArgs = applyThreeZeroGetProductsIdempotencyCompatibility( + name, + initialHandlerArgs, + idempotencyPrincipal, + ctx.storyboardCompat?.version === '3.0' || initialHandlerArgs.adcp_version === '3.0', + ); const idempotencyKey = (handlerArgs as { idempotency_key?: unknown }).idempotency_key; let toolResult: CallToolResult | null = null; let taskFailed = false; diff --git a/server/src/training-agent/tenants/tenant-smoke.test.ts b/server/src/training-agent/tenants/tenant-smoke.test.ts index 5d392894fe..2e3ba33f48 100644 --- a/server/src/training-agent/tenants/tenant-smoke.test.ts +++ b/server/src/training-agent/tenants/tenant-smoke.test.ts @@ -904,6 +904,39 @@ describe('tenant routing smoke', () => { } }, 15000); + it('adapts omitted get_products keys only on the frozen 3.0 compatibility route', async () => { + const { baseUrl, close } = await bootServer({ storyboardCompat: { version: '3.0' } }); + try { + const url = `${baseUrl}/sales/mcp`; + await initializeTenant(url); + const account = { + brand: { domain: 'tenant-products-legacy.example' }, + operator: 'tenant-products-legacy.example', + }; + const payload = { buying_mode: 'wholesale', account }; + const first = await callTenantTool(url, 2, 'get_products', payload) as { + result?: { structuredContent?: { products?: unknown[]; replayed?: boolean } }; + }; + const replay = await callTenantTool(url, 3, 'get_products', payload) as { + result?: { structuredContent?: { products?: unknown[]; replayed?: boolean } }; + }; + expect(first.result?.structuredContent?.products?.length).toBeGreaterThan(0); + expect(first.result?.structuredContent?.replayed).toBeUndefined(); + expect(replay.result?.structuredContent?.products).toEqual(first.result?.structuredContent?.products); + expect(replay.result?.structuredContent?.replayed).toBe(true); + + const changed = await callTenantTool(url, 4, 'get_products', { + buying_mode: 'brief', + brief: 'A different frozen 3.0 request', + account, + }) as { result?: { structuredContent?: { products?: unknown[]; replayed?: boolean } } }; + expect(changed.result?.structuredContent?.products).toBeDefined(); + expect(changed.result?.structuredContent?.replayed).toBeUndefined(); + } finally { + await close(); + } + }, 15000); + it('replays v6 get_products advisory-success responses', async () => { const { baseUrl, close } = await bootServer(); try { diff --git a/server/tests/unit/training-agent-idempotency.test.ts b/server/tests/unit/training-agent-idempotency.test.ts index a8da5a7d3e..2e706216fc 100644 --- a/server/tests/unit/training-agent-idempotency.test.ts +++ b/server/tests/unit/training-agent-idempotency.test.ts @@ -142,6 +142,7 @@ describe('training agent idempotency middleware', () => { it('rejects get_products with no idempotency_key before any polymorphic arm runs', async () => { const { parsed, isError } = await call(server, 'get_products', { + adcp_version: '3.1-rc.15', buying_mode: 'wholesale', account: ACCOUNT, brand: BRAND, @@ -150,6 +151,68 @@ describe('training agent idempotency middleware', () => { expect((parsed as any).adcp_error?.code).toBe('INVALID_REQUEST'); expect((parsed as any).adcp_error?.field).toBe('idempotency_key'); }); + + it('adapts only served-3.0 missing-key get_products requests to safe deterministic replay', async () => { + const legacyPayload = { + adcp_version: '3.0', + buying_mode: 'wholesale', + account: ACCOUNT, + brand: BRAND, + }; + const first = await call(server, 'get_products', { + ...legacyPayload, + context: { correlation_id: 'legacy-first' }, + }); + const replay = await call(server, 'get_products', { + ...legacyPayload, + adcp_major_version: 3, + context: { correlation_id: 'legacy-retry' }, + }); + expect(first.isError).toBeFalsy(); + expect(replay.isError).toBeFalsy(); + expect(replay.parsed.replayed).toBe(true); + expect((replay.parsed.context as { correlation_id?: string })?.correlation_id).toBe('legacy-retry'); + expect(replay.parsed.products).toEqual(first.parsed.products); + + // A materially changed legacy request gets a different derived key; it + // executes independently instead of aliasing or conflicting. + const changed = await call(server, 'get_products', { + ...legacyPayload, + buying_mode: 'brief', + brief: 'A distinct legacy discovery request', + }); + expect(changed.isError).toBeFalsy(); + expect(changed.parsed.replayed).toBeUndefined(); + expect(changed.parsed.products).toBeDefined(); + }); + + it('routes served-3.0 compatibility requests through task replay and finalize validation', async () => { + const legacyTask = { + adcp_version: '3.0', + buying_mode: 'wholesale', + account: ACCOUNT, + }; + const first = await callAsTask(server, 'get_products', legacyTask); + const replay = await callAsTask(server, 'get_products', legacyTask); + expect((replay.parsed.task as { taskId?: string })?.taskId) + .toBe((first.parsed.task as { taskId?: string })?.taskId); + expect(replay.parsed.replayed).toBe(true); + + const mixed = await call(server, 'get_products', { + adcp_version: '3.0', + buying_mode: 'refine', + account: ACCOUNT, + refine: [ + { scope: 'proposal', action: 'finalize', proposal_id: 'pinnacle_cross_channel' }, + { scope: 'proposal', action: 'include', proposal_id: 'pinnacle_cross_channel' }, + ], + }); + expect(mixed.isError).toBe(true); + expect((mixed.parsed as any).adcp_error).toMatchObject({ + code: 'INVALID_REQUEST', + field: 'refine[1]', + }); + }); }); describe('replay with same key + same payload', () => { From 60068ad9e823fd71eb9213632351413eeb2dcce4 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 9 Aug 2026 08:25:11 +0200 Subject: [PATCH 03/49] fix(ci): update get_products storyboards --- .../media-buy/scenarios/bidding_policy_placement.yaml | 1 + .../protocols/media-buy/scenarios/get_products_rejected.yaml | 1 + .../protocols/media-buy/scenarios/revenue_share_pricing.yaml | 1 + .../protocols/media-buy/scenarios/seller_optimized_budget.yaml | 3 +++ .../universal/wholesale-feed-products-scope-isolation.yaml | 2 ++ 5 files changed, 8 insertions(+) diff --git a/static/compliance/source/protocols/media-buy/scenarios/bidding_policy_placement.yaml b/static/compliance/source/protocols/media-buy/scenarios/bidding_policy_placement.yaml index 7f275a5608..9a9db70acf 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/bidding_policy_placement.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/bidding_policy_placement.yaml @@ -110,6 +110,7 @@ phases: stateful: false expected: "Return the seeded USD auction products." sample_request: + idempotency_key: "$generate:uuid_v4#bidding_policy_placement_setup_get_products_for_bidding" buying_mode: "brief" brief: "Buy two display packages with one inherited CPM ceiling." account: diff --git a/static/compliance/source/protocols/media-buy/scenarios/get_products_rejected.yaml b/static/compliance/source/protocols/media-buy/scenarios/get_products_rejected.yaml index 7b15ba3b8e..cfe4abe1b2 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/get_products_rejected.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/get_products_rejected.yaml @@ -116,6 +116,7 @@ phases: - suggestions[]: actionable alternatives - no products[], proposals[], errors[], or adcp_error sample_request: + idempotency_key: "$generate:uuid_v4#get_products_rejected_rejected_discovery_response_get_products_rejected" adcp_version: "3.2-beta.0" adcp_major_version: 3 buying_mode: "brief" diff --git a/static/compliance/source/protocols/media-buy/scenarios/revenue_share_pricing.yaml b/static/compliance/source/protocols/media-buy/scenarios/revenue_share_pricing.yaml index 52621bc250..10e6a3e029 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/revenue_share_pricing.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/revenue_share_pricing.yaml @@ -147,6 +147,7 @@ phases: Return the seeded affiliate product and only its revenue_share option. The option exposes its event source, rate, currency, and commission basis. sample_request: + idempotency_key: "$generate:uuid_v4#revenue_share_pricing_discovery_get_revenue_share_products" buying_mode: "wholesale" account: brand: diff --git a/static/compliance/source/protocols/media-buy/scenarios/seller_optimized_budget.yaml b/static/compliance/source/protocols/media-buy/scenarios/seller_optimized_budget.yaml index 978c68d716..48a0e938dc 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/seller_optimized_budget.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/seller_optimized_budget.yaml @@ -118,6 +118,7 @@ phases: stateful: false expected: "Return the seeded products with compatible USD pricing and click optimization." sample_request: + idempotency_key: "$generate:uuid_v4#seller_optimized_budget_setup_get_products_for_shared_budget" buying_mode: "brief" brief: "Allocate a $100,000 performance budget across prospecting and retargeting display packages, optimizing for clicks." account: @@ -533,6 +534,7 @@ phases: Return a seller-optimized proposal with aggregate even pacing and a subordinate front-loaded pacing term on the second allocation. sample_request: + idempotency_key: "$generate:uuid_v4#seller_optimized_budget_execute_seller_optimized_proposal_get_seller_optimized_proposal" buying_mode: "brief" brief: "Propose a $100,000 shared display budget across prospecting and retargeting, optimize allocation for clicks, pace the buy evenly, and front-load retargeting." account: @@ -569,6 +571,7 @@ phases: stateful: true expected: "Return the same seller-optimized proposal as committed firm terms." sample_request: + idempotency_key: "$generate:uuid_v4#seller_optimized_budget_execute_seller_optimized_proposal_finalize_seller_optimized_proposal" buying_mode: "refine" refine: - scope: "proposal" diff --git a/static/compliance/source/universal/wholesale-feed-products-scope-isolation.yaml b/static/compliance/source/universal/wholesale-feed-products-scope-isolation.yaml index 6bd05e0d31..c7e85b4bc8 100644 --- a/static/compliance/source/universal/wholesale-feed-products-scope-isolation.yaml +++ b/static/compliance/source/universal/wholesale-feed-products-scope-isolation.yaml @@ -64,6 +64,7 @@ phases: expected: | Return public-scope product rows with the public feed token. sample_request: + idempotency_key: "$generate:uuid_v4#wholesale_feed_products_scope_isolation_scope_isolation_bootstrap_public" buying_mode: "wholesale" account: brand: @@ -100,6 +101,7 @@ phases: 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: + idempotency_key: "$generate:uuid_v4#wholesale_feed_products_scope_isolation_scope_isolation_cross_scope_probe_not_unchanged" buying_mode: "wholesale" account: brand: From 77148931d845b044c6d8f8fe1cc973ef5ea8824f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 9 Aug 2026 12:43:55 +0200 Subject: [PATCH 04/49] fix(ci): persist in-process training mutations --- server/src/training-agent/task-handlers.ts | 6 +++++- .../tests/unit/training-agent-get-products-rejected.test.ts | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 91318265b6..94b283b76b 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -9757,7 +9757,11 @@ export async function executeTrainingAgentTool( args: ToolArgs, ctx: TrainingContext, ): Promise<{ success: boolean; data?: object; error?: string }> { - return runWithSessionContext(() => executeTrainingAgentToolInContext(toolName, args, ctx)); + return runWithSessionContext(async () => { + const result = await executeTrainingAgentToolInContext(toolName, args, ctx); + if (result.success) await flushDirtySessions(); + return result; + }); } async function executeTrainingAgentToolInContext( diff --git a/server/tests/unit/training-agent-get-products-rejected.test.ts b/server/tests/unit/training-agent-get-products-rejected.test.ts index 8d924f19fd..51a4b05652 100644 --- a/server/tests/unit/training-agent-get-products-rejected.test.ts +++ b/server/tests/unit/training-agent-get-products-rejected.test.ts @@ -71,6 +71,7 @@ describe('get_products rejected compliance arm', () => { const otherAccountResult = await call('get_products', { adcp_version: '3.2-beta.0', + idempotency_key: 'rejected-other-account-0001', account: otherAccount, buying_mode: 'brief', brief: 'Premium video', @@ -79,6 +80,7 @@ describe('get_products rejected compliance arm', () => { const otherPrincipalResult = await call('get_products', { adcp_version: '3.2-beta.0', + idempotency_key: 'rejected-other-principal-0001', account: primaryAccount, buying_mode: 'brief', brief: 'Premium video', @@ -87,6 +89,7 @@ describe('get_products rejected compliance arm', () => { const wholesaleResult = await call('get_products', { adcp_version: '3.2-beta.0', + idempotency_key: 'rejected-wholesale-0001', account: primaryAccount, buying_mode: 'wholesale', }); @@ -94,6 +97,7 @@ describe('get_products rejected compliance arm', () => { const rejected = await call('get_products', { adcp_version: '3.2-beta.0', + idempotency_key: 'rejected-primary-0001', account: primaryAccount, buying_mode: 'brief', brief: 'Premium video', @@ -112,6 +116,7 @@ describe('get_products rejected compliance arm', () => { const consumed = await call('get_products', { adcp_version: '3.2-beta.0', + idempotency_key: 'rejected-consumed-0001', account: primaryAccount, buying_mode: 'brief', brief: 'Premium video', From 7034689ce0cedf6c93d26dcebe2908d8f2dff2ed Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 9 Aug 2026 14:13:21 +0200 Subject: [PATCH 05/49] fix(security): fence get_products retries --- .changeset/secure-get-products-idempotency.md | 2 +- server/src/addie/config-version.ts | 2 +- server/src/training-agent/account-scope.ts | 122 ++++++ .../training-agent/comply-test-controller.ts | 10 +- server/src/training-agent/idempotency.ts | 364 +++++++++++++++- server/src/training-agent/state.ts | 143 ++++++- server/src/training-agent/task-handlers.ts | 400 +++++++++++------- .../tenants/custom-tool-helper.ts | 49 ++- server/src/training-agent/tenants/registry.ts | 4 +- server/src/training-agent/tenants/router.ts | 85 +++- .../tenants/tenant-smoke.test.ts | 41 +- .../src/training-agent/v6-sales-platform.ts | 31 +- .../tests/unit/comply-test-controller.test.ts | 2 +- server/tests/unit/idempotency-store.test.ts | 248 +++++++++++ server/tests/unit/idempotency.test.ts | 7 +- .../unit/training-agent-account-scope.test.ts | 169 ++++++++ ...aining-agent-get-products-rejected.test.ts | 3 - .../unit/training-agent-idempotency.test.ts | 128 +++++- server/tests/unit/training-agent.test.ts | 35 +- .../scenarios/get_products_async.yaml | 2 +- 20 files changed, 1624 insertions(+), 223 deletions(-) create mode 100644 server/src/training-agent/account-scope.ts create mode 100644 server/tests/unit/idempotency-store.test.ts create mode 100644 server/tests/unit/training-agent-account-scope.test.ts diff --git a/.changeset/secure-get-products-idempotency.md b/.changeset/secure-get-products-idempotency.md index 14d363d060..3c992cb9c5 100644 --- a/.changeset/secure-get-products-idempotency.md +++ b/.changeset/secure-get-products-idempotency.md @@ -1,5 +1,5 @@ --- -"adcontextprotocol": patch +"adcontextprotocol": major --- Require idempotency keys for polymorphic `get_products` requests so asynchronous discovery and proposal finalization are safe to retry. diff --git a/server/src/addie/config-version.ts b/server/src/addie/config-version.ts index 5bab94834a..e280fd54b5 100644 --- a/server/src/addie/config-version.ts +++ b/server/src/addie/config-version.ts @@ -30,7 +30,7 @@ import { loadRules, loadResponseStyle } from './rules/index.js'; * Format: YYYY.MM.N where N is incremented for multiple changes in a month * Example: 2025.01.1, 2025.01.2, 2025.02.1 */ -export const CODE_VERSION = '2026.08.3'; +export const CODE_VERSION = '2026.08.4'; // Types export interface ConfigVersion { diff --git a/server/src/training-agent/account-scope.ts b/server/src/training-agent/account-scope.ts new file mode 100644 index 0000000000..5cb44fcee6 --- /dev/null +++ b/server/src/training-agent/account-scope.ts @@ -0,0 +1,122 @@ +import type { AccountRef } from './types.js'; + +const ACCOUNT_ID_KEYS = new Set(['account_id']); +const NATURAL_ACCOUNT_KEYS = new Set(['brand', 'operator', 'sandbox']); +const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i; +const BRAND_ID_RE = /^[a-z0-9_]+$/; +const MAX_DOMAIN_LENGTH = 253; + +export type CanonicalAccountRef = + | { kind: 'account_id'; account_id: string } + | { + kind: 'natural'; + brand: { domain: string; brand_id?: string }; + operator: string; + sandbox: boolean; + }; + +export class AccountRefValidationError extends Error { + readonly field = 'account'; + + constructor(message: string) { + super(message); + this.name = 'AccountRefValidationError'; + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function invalid(message: string): never { + throw new AccountRefValidationError(message); +} + +function normalizedDomain(value: unknown, field: string): string { + if ( + typeof value !== 'string' + || value.length === 0 + || value.length > MAX_DOMAIN_LENGTH + || !DOMAIN_RE.test(value) + ) { + return invalid(`${field} must be a valid domain name.`); + } + return value.toLowerCase(); +} + +/** + * Validate AccountRef's exact-oneOf identity and return only canonical fields. + * + * AccountRef is a closed union: either the sole `account_id` field, or the + * natural `brand` + `operator` identity with optional `sandbox`. Checking + * property presence (rather than truthiness) ensures mixed, incomplete, and + * unknown shapes cannot be silently interpreted as a different account. + */ +export function canonicalizeAccountRef(value: unknown): CanonicalAccountRef { + if (!isRecord(value)) invalid('account must be an object.'); + + const keys = Object.keys(value); + const hasAccountId = Object.prototype.hasOwnProperty.call(value, 'account_id'); + const hasNaturalField = keys.some(key => NATURAL_ACCOUNT_KEYS.has(key)); + + if (hasAccountId && hasNaturalField) { + invalid('account must use exactly one identity: account_id or brand + operator.'); + } + + if (hasAccountId) { + const unknown = keys.filter(key => !ACCOUNT_ID_KEYS.has(key)); + if (unknown.length > 0) { + invalid(`account_id references do not allow field '${unknown[0]}'.`); + } + if (typeof value.account_id !== 'string' || value.account_id.length === 0) { + invalid('account.account_id must be a non-empty string.'); + } + return { kind: 'account_id', account_id: value.account_id }; + } + + const unknown = keys.filter(key => !NATURAL_ACCOUNT_KEYS.has(key)); + if (unknown.length > 0) { + invalid(`natural account references do not allow field '${unknown[0]}'.`); + } + if (!Object.prototype.hasOwnProperty.call(value, 'brand') + || !Object.prototype.hasOwnProperty.call(value, 'operator')) { + invalid('account must use exactly one identity: account_id or brand + operator.'); + } + if (!isRecord(value.brand)) invalid('account.brand must be an object.'); + + const domain = normalizedDomain(value.brand.domain, 'account.brand.domain'); + const operator = normalizedDomain(value.operator, 'account.operator'); + let brandId: string | undefined; + if (Object.prototype.hasOwnProperty.call(value.brand, 'brand_id')) { + if (typeof value.brand.brand_id !== 'string' || !BRAND_ID_RE.test(value.brand.brand_id)) { + invalid('account.brand.brand_id must contain lowercase letters, digits, or underscores.'); + } + brandId = value.brand.brand_id; + } + if (value.sandbox !== undefined && typeof value.sandbox !== 'boolean') { + invalid('account.sandbox must be a boolean when provided.'); + } + + return { + kind: 'natural', + brand: { + domain, + ...(brandId !== undefined && { brand_id: brandId }), + }, + operator, + sandbox: value.sandbox ?? false, + }; +} + +/** Stable account partition shared by sessions and idempotency claims. */ +export function accountScopeFromRef(value: AccountRef | unknown): string { + const account = canonicalizeAccountRef(value); + if (account.kind === 'account_id') return `a:${account.account_id}`; + return [ + 'n', + account.brand.domain, + account.brand.brand_id ?? '-', + account.operator, + account.sandbox ? '1' : '0', + ].join(':'); +} diff --git a/server/src/training-agent/comply-test-controller.ts b/server/src/training-agent/comply-test-controller.ts index b55f485035..6f7e388964 100644 --- a/server/src/training-agent/comply-test-controller.ts +++ b/server/src/training-agent/comply-test-controller.ts @@ -34,7 +34,7 @@ import type { ComplyBudgetSimulation, } from './types.js'; import { supportsGetProductsRejected } from './types.js'; -import { getSession, sessionKeyFromArgs } from './state.js'; +import { getProductsSessionKeyFromArgs, getSession, sessionKeyFromArgs } from './state.js'; import { getAgentUrl } from './config.js'; import { randomUUID } from 'node:crypto'; import { getAccountNotificationSubscribers, seedAccountFixture } from './account-handlers.js'; @@ -1124,13 +1124,17 @@ export async function handleComplyTestController(args: ToolArgs, ctx: TrainingCo }; } - const sessionKey = sessionKeyFromArgs(args, ctx.mode, ctx.userId, ctx.moduleId); + const scenario = rawArgs.scenario; + const targetsGetProductsState = scenario === 'force_get_products_arm' + || (scenario === 'force_upstream_unavailable' && params.tool === 'get_products'); + const sessionKey = targetsGetProductsState + ? getProductsSessionKeyFromArgs(args, ctx.mode, ctx.userId, ctx.moduleId) + : sessionKeyFromArgs(args, ctx.mode, ctx.userId, ctx.moduleId); const session = await getSession(sessionKey); // Pre-dispatch local scenarios the SDK doesn't know about yet. The SDK's // dispatcher would return UNKNOWN_SCENARIO for these, so handle them before // we delegate. New scenarios from spec PRs land here until adopted upstream. - const scenario = rawArgs.scenario; if (scenario === 'force_create_media_buy_arm') { return handleForceCreateMediaBuyArm(session, rawArgs); } diff --git a/server/src/training-agent/idempotency.ts b/server/src/training-agent/idempotency.ts index 7637ad8438..9dddd90d3c 100644 --- a/server/src/training-agent/idempotency.ts +++ b/server/src/training-agent/idempotency.ts @@ -1,8 +1,8 @@ /** * Idempotency wiring for the training agent. * - * Thin facade over `@adcp/sdk/server`'s `createIdempotencyStore`, which - * implements the spec behaviour (RFC 8785 JCS payload hash, atomic + * Facade over `@adcp/sdk/server`'s idempotency backends, implementing the + * spec behaviour (RFC 8785 JCS payload hash, atomic hash-aware * putIfAbsent claim, ±60s clock-skew TTL, exclusion list for * `idempotency_key`, `context`, `governance_context`, and * `push_notification_config.authentication.credentials`). @@ -23,17 +23,29 @@ */ import { - createIdempotencyStore, memoryBackend, pgBackend, hashPayload, type IdempotencyStore, + type IdempotencyBackend, type IdempotencyCheckResult, } from '@adcp/sdk/server'; import { isDatabaseInitialized, getPool } from '../db/client.js'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; export const REPLAY_TTL_SECONDS = 86400; +const MIN_TTL_SECONDS = 3600; +const MAX_TTL_SECONDS = 604800; +const DEFAULT_CLOCK_SKEW_SECONDS = 60; +const IN_FLIGHT_TTL_SECONDS = 120; +const TRANSIENT_ERROR_TTL_SECONDS = 10; +const IN_FLIGHT_RETRY_HINT_CAP_SECONDS = 30; +const CLAIM_SAVE_SAFETY_SECONDS = 30; +const SCOPE_SEPARATOR = '\u001F'; +const PENDING_OWNER_FIELD = '__adcp_pending_owner'; + const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9_.:-]{16,255}$/; /** @@ -113,14 +125,289 @@ export function payloadHash(payload: unknown): string { // ── Store factory ──────────────────────────────────────────────── -let storeInstance: IdempotencyStore | null = null; +type OwnedCheckResult = Exclude | { + kind: 'miss'; + payloadHash: string; + claimToken: string; +}; + +export type OwnedIdempotencyStore = Omit & { + check(params: Parameters[0]): Promise; + save(params: Parameters[0] & { claimToken: string }): Promise; + release(params: Parameters[0] & { claimToken: string }): Promise; + saveTransientError?(params: Parameters>[0] & { + claimToken: string; + }): Promise; +}; + +interface FencedIdempotencyBackend extends IdempotencyBackend { + replaceIfPendingOwner?( + scopedKey: string, + owner: string, + entry: Parameters[1], + minimumLeaseExpiry: number, + ): Promise; + deleteIfPendingOwner?(scopedKey: string, owner: string): Promise; +} + +const backendLockTails = new WeakMap>>(); + +async function withBackendKeyLock(backend: object, key: string, fn: () => Promise): Promise { + let tails = backendLockTails.get(backend); + if (!tails) { + tails = new Map(); + backendLockTails.set(backend, tails); + } + const prior = tails.get(key) ?? Promise.resolve(); + let unlock!: () => void; + const gate = new Promise(resolve => { unlock = resolve; }); + const tail = prior.then(() => gate); + tails.set(key, tail); + await prior; + try { + return await fn(); + } finally { + unlock(); + if (tails.get(key) === tail) tails.delete(key); + } +} + +let storeInstance: OwnedIdempotencyStore | null = null; +let sdkStoreAdapter: IdempotencyStore | null = null; + +interface HashAwareIdempotencyStoreConfig { + backend: FencedIdempotencyBackend; + ttlSeconds?: number; + clockSkewSeconds?: number; +} + +/** + * Build an SDK-compatible store whose atomic claim records the request's real + * hash. The SDK store currently claims with a payload-independent sentinel, + * which cannot distinguish a concurrent retry from same-key reuse with a + * different payload until the first request has completed. + */ +export function createHashAwareIdempotencyStore( + config: HashAwareIdempotencyStoreConfig, +): OwnedIdempotencyStore { + if (!config || typeof config !== 'object') { + throw new TypeError('createHashAwareIdempotencyStore requires a configuration object.'); + } + if (!config.backend) { + throw new TypeError('createHashAwareIdempotencyStore requires an idempotency backend.'); + } + const ttlSeconds = validateTtl(config.ttlSeconds ?? REPLAY_TTL_SECONDS); + const clockSkewSeconds = config.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS; + const { backend } = config; + + const scopedKey = (principal: string, key: string, extraScope?: string): string => + extraScope + ? `${principal}${SCOPE_SEPARATOR}${extraScope}${SCOPE_SEPARATOR}${key}` + : `${principal}${SCOPE_SEPARATOR}${key}`; + + const retryAfterSeconds = (expiresAt: number, nowSeconds: number): number => + Math.max(1, Math.min(IN_FLIGHT_RETRY_HINT_CAP_SECONDS, expiresAt - nowSeconds)); + + const pendingOwner = (response: unknown): string | undefined => ( + response !== null + && typeof response === 'object' + && !Array.isArray(response) + && typeof (response as Record)[PENDING_OWNER_FIELD] === 'string' + ? (response as Record)[PENDING_OWNER_FIELD] + : undefined + ); + + const isPending = (response: unknown): boolean => response === null || pendingOwner(response) !== undefined; + + const classify = ( + entry: Awaited>, + expectedHash: string, + ): Exclude | null => { + if (!entry) return null; + const nowSeconds = Math.floor(Date.now() / 1e3); + if (entry.expiresAt + clockSkewSeconds < nowSeconds) { + return isPending(entry.response) ? null : { kind: 'expired' }; + } + if (entry.payloadHash !== expectedHash) { + return { kind: 'conflict' }; + } + // AdCP handler responses are non-null objects, so null is an explicit + // pending marker that both SDK backends can persist atomically. + if (isPending(entry.response)) { + return { + kind: 'in-flight', + retryAfterSeconds: retryAfterSeconds(entry.expiresAt, nowSeconds), + }; + } + return { kind: 'replay', response: entry.response }; + }; + + const checkAndClaim = async ( + principal: string, + key: string, + payload: unknown, + extraScope?: string, + ): Promise => { + const cacheKey = scopedKey(principal, key, extraScope); + const expectedHash = hashPayload(payload); + const cached = classify(await backend.get(cacheKey), expectedHash); + if (cached) return cached; -export function getIdempotencyStore(): IdempotencyStore { + const expiresAt = Math.floor(Date.now() / 1e3) + IN_FLIGHT_TTL_SECONDS; + const owner = randomUUID(); + const claimed = await backend.putIfAbsent(cacheKey, { + payloadHash: expectedHash, + response: { [PENDING_OWNER_FIELD]: owner }, + expiresAt, + }); + if (claimed) return { kind: 'miss', payloadHash: expectedHash, claimToken: owner }; + + const rechecked = classify(await backend.get(cacheKey), expectedHash); + return rechecked ?? { kind: 'in-flight', retryAfterSeconds: 1 }; + }; + + return { + ttlSeconds, + check({ principal, key, payload, extraScope }) { + const cacheKey = scopedKey(principal, key, extraScope); + const operation = () => checkAndClaim(principal, key, payload, extraScope); + return backend.replaceIfPendingOwner && backend.deleteIfPendingOwner + ? operation() + : withBackendKeyLock(backend, cacheKey, operation); + }, + async save({ principal, key, payloadHash, response, extraScope, claimToken }) { + const cacheKey = scopedKey(principal, key, extraScope); + const saved = await replaceOwnedClaim(cacheKey, claimToken, backend, pendingOwner, { + payloadHash, + response, + expiresAt: Math.floor(Date.now() / 1e3) + ttlSeconds, + }); + if (!saved) throw new Error('Idempotency claim ownership was lost before the response could be published.'); + }, + async release({ principal, key, extraScope, claimToken }) { + const cacheKey = scopedKey(principal, key, extraScope); + await deleteOwnedClaim(cacheKey, claimToken, backend, pendingOwner); + }, + async saveTransientError({ principal, key, payloadHash, response, extraScope, claimToken }) { + const cacheKey = scopedKey(principal, key, extraScope); + const saved = await replaceOwnedClaim(cacheKey, claimToken, backend, pendingOwner, { + payloadHash, + response, + expiresAt: Math.floor(Date.now() / 1e3) + TRANSIENT_ERROR_TTL_SECONDS, + }); + if (!saved) throw new Error('Idempotency claim ownership was lost before the response could be published.'); + }, + async probe() { + if (backend.probe) await backend.probe(); + }, + capability() { + return { replay_ttl_seconds: ttlSeconds }; + }, + async close() { + if (backend.close) await backend.close(); + }, + ...(backend.clearAll + ? { + async clearAll() { + await backend.clearAll!(); + }, + } + : {}), + }; +} + +async function replaceOwnedClaim( + cacheKey: string, + owner: string, + backend: FencedIdempotencyBackend, + pendingOwner: (response: unknown) => string | undefined, + entry: Parameters[1], +): Promise { + const nowSeconds = Math.floor(Date.now() / 1e3); + const minimumLeaseExpiry = nowSeconds + CLAIM_SAVE_SAFETY_SECONDS; + if (backend.replaceIfPendingOwner) { + return backend.replaceIfPendingOwner(cacheKey, owner, entry, minimumLeaseExpiry); + } + return withBackendKeyLock(backend, cacheKey, async () => { + const current = await backend.get(cacheKey); + if (!current || pendingOwner(current.response) !== owner || current.expiresAt <= minimumLeaseExpiry) { + return false; + } + await backend.put(cacheKey, entry); + return true; + }); +} + +async function deleteOwnedClaim( + cacheKey: string, + owner: string, + backend: FencedIdempotencyBackend, + pendingOwner: (response: unknown) => string | undefined, +): Promise { + if (backend.deleteIfPendingOwner) return backend.deleteIfPendingOwner(cacheKey, owner); + return withBackendKeyLock(backend, cacheKey, async () => { + const current = await backend.get(cacheKey); + if (!current || pendingOwner(current.response) !== owner) return false; + await backend.delete(cacheKey); + return true; + }); +} + +function validateTtl(seconds: number): number { + if (!Number.isFinite(seconds) || !Number.isInteger(seconds)) { + throw new Error(`createIdempotencyStore: ttlSeconds must be a finite integer. Got ${seconds}.`); + } + if (seconds < MIN_TTL_SECONDS) { + throw new Error( + `createIdempotencyStore: ttlSeconds must be >= ${MIN_TTL_SECONDS} ` + + `(1 hour per AdCP spec). Got ${seconds} — did you mean minutes?`, + ); + } + if (seconds > MAX_TTL_SECONDS) { + throw new Error( + `createIdempotencyStore: ttlSeconds must be <= ${MAX_TTL_SECONDS} ` + + `(7 days per AdCP spec). Got ${seconds}.`, + ); + } + return seconds; +} + +function fencedPgBackend(): FencedIdempotencyBackend { + const db = getPool(); + const base = pgBackend(db); + return { + ...base, + async replaceIfPendingOwner(cacheKey, owner, entry, minimumLeaseExpiry) { + const result = await db.query( + `UPDATE adcp_idempotency + SET payload_hash = $3, + response = $4::jsonb, + expires_at = TO_TIMESTAMP($5) + WHERE scoped_key = $1 + AND response ->> '__adcp_pending_owner' = $2 + AND expires_at > TO_TIMESTAMP($6)`, + [cacheKey, owner, entry.payloadHash, JSON.stringify(entry.response), entry.expiresAt, minimumLeaseExpiry], + ); + return (result.rowCount ?? 0) > 0; + }, + async deleteIfPendingOwner(cacheKey, owner) { + const result = await db.query( + `DELETE FROM adcp_idempotency + WHERE scoped_key = $1 + AND response ->> '__adcp_pending_owner' = $2`, + [cacheKey, owner], + ); + return (result.rowCount ?? 0) > 0; + }, + }; +} + +export function getIdempotencyStore(): OwnedIdempotencyStore { if (storeInstance) return storeInstance; const backend = isDatabaseInitialized() - ? pgBackend(getPool()) + ? fencedPgBackend() : memoryBackend(); - const base = createIdempotencyStore({ backend, ttlSeconds: REPLAY_TTL_SECONDS }); + const base = createHashAwareIdempotencyStore({ backend, ttlSeconds: REPLAY_TTL_SECONDS }); storeInstance = { ...base, check: params => base.check({ ...params, payload: normalizeIdempotencyPayload(params.payload) }), @@ -128,6 +415,68 @@ export function getIdempotencyStore(): IdempotencyStore { return storeInstance; } +const SDK_CLAIM_SEPARATOR = '.'; +const sdkClaimContext = new AsyncLocalStorage>(); + +function sdkClaimKey(params: { principal: string; key: string; extraScope?: string }): string { + return `${params.principal}${SCOPE_SEPARATOR}${params.extraScope ?? ''}${SCOPE_SEPARATOR}${params.key}`; +} + +function decodeSdkClaim(encoded: string): { payloadHash: string; claimToken: string } { + const separator = encoded.lastIndexOf(SDK_CLAIM_SEPARATOR); + if (separator <= 0 || separator === encoded.length - 1) { + throw new Error('SDK idempotency claim is missing its fencing token.'); + } + return { + payloadHash: encoded.slice(0, separator), + claimToken: encoded.slice(separator + 1), + }; +} + +/** Adapt the token-aware store to the SDK's payloadHash-only claim contract. */ +export function adaptOwnedIdempotencyStoreForSdk(owned: OwnedIdempotencyStore): IdempotencyStore { + return { + ...owned, + check(params) { + // enterWith must happen synchronously, before returning the Promise, so + // the SDK's awaiting continuation inherits this request-local claim map. + const claims = new Map(sdkClaimContext.getStore()); + sdkClaimContext.enterWith(claims); + return owned.check(params).then(result => { + if (result.kind !== 'miss') return result; + claims.set(sdkClaimKey(params), result.claimToken); + return { + ...result, + payloadHash: `${result.payloadHash}${SDK_CLAIM_SEPARATOR}${result.claimToken}`, + }; + }); + }, + async save(params) { + await owned.save({ ...params, ...decodeSdkClaim(params.payloadHash) }); + sdkClaimContext.getStore()?.delete(sdkClaimKey(params)); + }, + async release(params) { + const claims = sdkClaimContext.getStore(); + const key = sdkClaimKey(params); + const claimToken = claims?.get(key); + if (!claimToken) return; + await owned.release({ ...params, claimToken }); + claims?.delete(key); + }, + async saveTransientError(params) { + if (!owned.saveTransientError) return; + await owned.saveTransientError({ ...params, ...decodeSdkClaim(params.payloadHash) }); + sdkClaimContext.getStore()?.delete(sdkClaimKey(params)); + }, + }; +} + +export function getSdkIdempotencyStore(): IdempotencyStore { + if (sdkStoreAdapter) return sdkStoreAdapter; + sdkStoreAdapter = adaptOwnedIdempotencyStoreForSdk(getIdempotencyStore()); + return sdkStoreAdapter; +} + function normalizeIdempotencyPayload(payload: unknown): unknown { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload; const record = payload as Record; @@ -147,6 +496,7 @@ function normalizeIdempotencyPayload(payload: unknown): unknown { export async function clearIdempotencyCache(): Promise { const current = storeInstance; storeInstance = null; + sdkStoreAdapter = null; if (current) await current.close(); } diff --git a/server/src/training-agent/state.ts b/server/src/training-agent/state.ts index aea0b3ecc0..cdba98f0bf 100644 --- a/server/src/training-agent/state.ts +++ b/server/src/training-agent/state.ts @@ -15,7 +15,9 @@ */ import { AsyncLocalStorage } from 'node:async_hooks'; +import { createHash } from 'node:crypto'; import type { SessionState, AccountRef, BrandRef, CreativeState, MediaBuyState, PackageState } from './types.js'; +import { accountScopeFromRef, canonicalizeAccountRef } from './account-scope.js'; import { cleanupExpiredTasks } from '@adcp/sdk'; import { InMemoryStateStore, @@ -111,10 +113,11 @@ export function runWithSessionContext(fn: () => Promise): Promise { * `StateError('INVALID_ID')` automatically. Failures bubble to the MCP * transport layer so operators notice in alert pipelines. * - * Known limitation: concurrent requests against the same session key use - * last-writer-wins semantics. Acceptable for the sandbox training agent where - * storyboards are sequential. Production sellers should use per-entity - * collections via @adcp/sdk's createAdcpServer instead. + * Concurrent requests merge disjoint top-level session fields. Requests that + * mutate the same top-level field still use last-writer-wins semantics, which + * is acceptable for the sandbox training agent where storyboards are + * sequential. Production sellers should use per-entity collections via + * @adcp/sdk's createAdcpServer instead. */ export async function flushDirtySessions(): Promise { const ctx = requestCtx.getStore(); @@ -127,8 +130,17 @@ export async function flushDirtySessions(): Promise { const snapshotJson = ctx.snapshots.get(key); if (snapshotJson === currentJson) continue; try { - await store.put(SESSIONS_COLLECTION, key, current); - ctx.snapshots.set(key, currentJson); + // Merge top-level fields against the newest durable document. This + // prevents a get_products proposal write from erasing an unrelated + // media-buy/creative mutation (and vice versa) when both requests + // loaded the same older snapshot. Same-field conflicts remain + // last-writer-wins, which is sufficient for this sandbox store. + const snapshot = snapshotJson + ? JSON.parse(snapshotJson) as Record + : {}; + const merged = await persistMergedSession(store, key, snapshot, current); + ctx.snapshots.set(key, stableStringify(merged)); + ctx.sessions.set(key, deserializeSession(merged)); } catch (err) { // The response has already been sent, so we can't surface to the // caller. Collect for an aggregate throw so the MCP transport @@ -144,6 +156,51 @@ export async function flushDirtySessions(): Promise { } } +async function persistMergedSession( + store: AdcpStateStore, + key: string, + snapshot: Record, + current: Record, +): Promise> { + if (!store.getWithVersion || !store.putIfMatch) { + const latest = await store.get>(SESSIONS_COLLECTION, key); + const merged = latest ? mergeTopLevelSessionChanges(snapshot, current, latest) : current; + await store.put(SESSIONS_COLLECTION, key, merged); + return merged; + } + + for (let attempt = 0; attempt < 5; attempt++) { + const latest = await store.getWithVersion>(SESSIONS_COLLECTION, key); + const merged = latest + ? mergeTopLevelSessionChanges(snapshot, current, latest.data) + : current; + const result = await store.putIfMatch( + SESSIONS_COLLECTION, + key, + merged, + latest?.version ?? null, + ); + if (result.ok) return merged; + } + throw new Error(`Concurrent session update did not converge after 5 attempts: ${key}`); +} + +function mergeTopLevelSessionChanges( + snapshot: Record, + current: Record, + latest: Record, +): Record { + const merged: Record = {}; + const keys = new Set([...Object.keys(snapshot), ...Object.keys(current), ...Object.keys(latest)]); + for (const key of keys) { + const changedLocally = stableStringify({ value: current[key] }) + !== stableStringify({ value: snapshot[key] }); + const value = changedLocally ? current[key] : latest[key]; + if (value !== undefined) merged[key] = value; + } + return merged; +} + /** * Stable stringify for dirty-detection. * @@ -477,6 +534,26 @@ function safeKey(value: string | undefined, max: number, pattern: RegExp): strin return value; } +function principalDigest(principal: string): string { + return createHash('sha256').update(principal).digest('hex'); +} + +function principalScopedOpenKey(principal: string, scope: string): string { + const prefix = `open:p:${principalDigest(principal)}:`; + const candidate = `${prefix}${scope}`; + // The SDK state store caps IDs at 256 characters. Preserve readable scopes + // when possible and hash only the canonical scope when a maximal pair of + // domains (or an opaque seller account id) would exceed that limit. + if (candidate.length <= 256 && /^[A-Za-z0-9_.\-:]+$/.test(candidate)) return candidate; + return `${prefix}h:${createHash('sha256').update(scope).digest('hex')}`; +} + +function canonicalOpenKey(scope: string, preferred?: string): string { + const candidate = preferred ?? `open:${scope}`; + if (candidate.length <= 256 && /^[A-Za-z0-9_.\-:]+$/.test(candidate)) return candidate; + return `open:h:${createHash('sha256').update(scope).digest('hex')}`; +} + /** Derive a session key from the request context. * * Rejects malformed domain/account_id values — they become part of a Postgres @@ -508,6 +585,7 @@ export function sessionKeyFromArgs( mode: 'open' | 'training', userId?: string, moduleId?: string, + principal?: string, ): string { if (mode === 'training' && userId) { const safeUser = safeKey(userId, 128, SAFE_ACCOUNT_ID_RE) ?? 'default'; @@ -515,6 +593,49 @@ export function sessionKeyFromArgs( return `training:${safeUser}:${safeModule}`; } const account = args.account; + if (account !== undefined) { + try { + const canonical = canonicalizeAccountRef(account); + const scope = accountScopeFromRef(account); + if (principal) return principalScopedOpenKey(principal, scope); + if (canonical.kind === 'account_id') { + return canonicalOpenKey(scope); + } + if ( + canonical.brand.brand_id === undefined + && canonical.operator === canonical.brand.domain + && canonical.sandbox === false + ) { + return canonicalOpenKey(scope, `open:${canonical.brand.domain}`); + } + return canonicalOpenKey(scope); + } catch (error) { + if (principal) throw error; + // Legacy training fixtures predate AccountRef and may carry only a + // brand, or combine account_id with the storyboard brand invariant. + // Production dispatch validates get_products before reaching here. + } + } + // Dispatcher paths that opt into authenticated scoping also partition + // brand/plans fallbacks. Callers without a principal retain legacy keys. + if (principal) { + const fallbackDomain = args.brand?.domain + ?? (Array.isArray(args.plans) && args.plans.length > 0 + ? (args.plans[0] as { brand?: BrandRef } | undefined)?.brand?.domain + : undefined) + ?? (Array.isArray(args.accounts) && args.accounts.length > 0 + ? ((args.accounts[0] as { brand?: BrandRef; account?: AccountRef } | undefined)?.brand?.domain + ?? (args.accounts[0] as { account?: AccountRef } | undefined)?.account?.brand?.domain) + : undefined); + const safeFallback = safeKey(fallbackDomain, MAX_DOMAIN_LEN, SAFE_DOMAIN_RE); + if (safeFallback) { + return principalScopedOpenKey(principal, `b:${safeFallback.toLowerCase()}`); + } + if (fallbackDomain) { + logger.debug({ domain: fallbackDomain }, 'Rejected fallback brand.domain as session key; falling back'); + } + return principalScopedOpenKey(principal, 'default'); + } const domain = account?.brand?.domain ?? args.brand?.domain; const safeDomain = safeKey(domain, MAX_DOMAIN_LEN, SAFE_DOMAIN_RE); if (safeDomain) { @@ -549,6 +670,16 @@ export function sessionKeyFromArgs( return 'open:default'; } +/** Canonical account partition for get_products proposal and replay state. */ +export function getProductsSessionKeyFromArgs( + args: { account?: AccountRef; brand?: BrandRef }, + mode: 'open' | 'training', + userId?: string, + moduleId?: string, +): string { + return sessionKeyFromArgs(args, mode, userId, moduleId); +} + // ── TTL cleanup ────────────────────────────────────────────────── let cleanupTimer: ReturnType | null = null; diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 94b283b76b..6577e327c0 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -23,6 +23,7 @@ import { isDatabaseInitialized, getPool } from '../db/client.js'; import { createLogger } from '../logger.js'; import { isPrivateHostname, normalizeExternalHostname, safeFetchAxiosLike } from '../utils/url-security.js'; import { GET_PRODUCTS_REJECTED_ADCP_VERSION, supportsGetProductsRejected, type TrainingContext, type CatalogProduct, type MediaBuyState, type MediaBuyAvailableActionState, type MediaBuyProductAllowedActionState, type PackageState, type SignalActivationState, type CreativeState, type CreativeManifest, type ToolArgs, type ListReference, type PackageTargeting, type AccountRef, type SessionState } from './types.js'; +import { AccountRefValidationError, accountScopeFromRef } from './account-scope.js'; import { encodeOffsetCursor, decodeOffsetCursor } from './pagination.js'; import type { Product, @@ -56,7 +57,7 @@ function escapeHtmlAttr(s: string): string { } /** Build a structured MCP error response for tool calls (L3 error compliance). */ -export function adcpError(code: string, opts: { message: string; details?: unknown; recovery?: string; field?: string }, context?: unknown, adcpVersion?: string) { +export function adcpError(code: string, opts: { message: string; details?: unknown; recovery?: string; field?: string; retry_after?: number }, context?: unknown, adcpVersion?: string) { const errorObj = { code, ...opts }; const body = context !== undefined ? { adcp_error: errorObj, context } @@ -1334,7 +1335,7 @@ import { buildCatalog, buildShowsForProducts, buildProposals } from './product-f import { buildFormats, FORMAT_CHANNEL_MAP } from './formats.js'; import { getAllSignals, SIGNAL_PROVIDERS } from './signal-providers.js'; import { - getSession, sessionKeyFromArgs, + getSession, getProductsSessionKeyFromArgs, sessionKeyFromArgs, findSessionMatching, runWithSessionContext, flushDirtySessions, getComplianceCreatives, getComplianceCreative, @@ -1781,20 +1782,32 @@ function toolSupportsTask(toolName: string): boolean { * its only job is to keep two different buyers on the same shared token * from seeing each other's idempotency outcomes. */ -function deriveAccountScope(args: Record): string | undefined { +function deriveAccountScope(args: Record, strictAccountRef = true): string | undefined { const usageAccount = Array.isArray(args.usage) ? (args.usage[0] as { account?: unknown } | undefined)?.account : undefined; - const account = (args.account ?? usageAccount) as { account_id?: string; brand?: { domain?: string } } | undefined; - if (account?.account_id && typeof account.account_id === 'string') { - return `a:${account.account_id}`; - } - const domain = account?.brand?.domain - ?? (args.brand as { domain?: string } | undefined)?.domain; - if (typeof domain === 'string' && domain.length > 0) { - return `b:${domain.toLowerCase()}`; + const account = Object.prototype.hasOwnProperty.call(args, 'account') + ? args.account + : usageAccount; + if (account !== undefined) { + try { + return accountScopeFromRef(account); + } catch (error) { + if (strictAccountRef || !(error instanceof AccountRefValidationError)) throw error; + const legacy = account as { account_id?: unknown; brand?: { domain?: unknown } }; + if (typeof legacy.account_id === 'string' && legacy.account_id.length > 0) { + return `a:${legacy.account_id}`; + } + if (typeof legacy.brand?.domain === 'string' && legacy.brand.domain.length > 0) { + return `b:${legacy.brand.domain.toLowerCase()}`; + } + return undefined; + } } - return undefined; + const domain = (args.brand as { domain?: unknown } | undefined)?.domain; + return typeof domain === 'string' && domain.length > 0 + ? `b:${domain.toLowerCase()}` + : undefined; } function withUsageAccountScope>(req: T): T { @@ -3782,11 +3795,23 @@ const TOOLS = [ ]; function visibleToolsForContext(ctx: TrainingContext): typeof TOOLS { - return TOOLS.filter(tool => { - if (tool.name !== 'validate_input') return true; - if (isThreeZeroStoryboardCompat(ctx)) return false; - return true; - }) as typeof TOOLS; + const threeZero = isThreeZeroStoryboardCompat(ctx); + return TOOLS + .filter(tool => tool.name !== 'validate_input' || !threeZero) + .map(tool => { + if (!threeZero || tool.name !== 'get_products') return tool; + const properties = { ...tool.inputSchema.properties }; + delete (properties as Record).idempotency_key; + return { + ...tool, + annotations: { ...tool.annotations, readOnlyHint: true, idempotentHint: true }, + inputSchema: { + ...tool.inputSchema, + properties, + required: tool.inputSchema.required?.filter(field => field !== 'idempotency_key'), + }, + }; + }) as typeof TOOLS; } export function visibleTrainingToolNamesForContext(ctx: TrainingContext): string[] { @@ -3799,70 +3824,52 @@ function toolAvailableForServedAdcpVersion(toolName: string, servedAdcpVersion: // ── Task handler implementations ────────────────────────────────── -function finalizedProposalIds(args: ToolArgs): string[] { - const refine = (args as unknown as { refine?: unknown }).refine; - if (!Array.isArray(refine)) return []; - return [...new Set(refine - .filter((entry): entry is { scope: 'proposal'; proposal_id: string; action: 'finalize' } => ( - typeof entry === 'object' - && entry !== null - && (entry as { scope?: unknown }).scope === 'proposal' - && (entry as { action?: unknown }).action === 'finalize' - && typeof (entry as { proposal_id?: unknown }).proposal_id === 'string' - )) - .map(entry => entry.proposal_id))] - .sort(); -} - export async function handleGetProducts(args: ToolArgs, ctx: TrainingContext): Promise { - const proposalIds = finalizedProposalIds(args); - if (proposalIds.length === 0) return handleGetProductsUnlocked(args, ctx); - - // Serialize proposal commits independently of the buyer's request key. - // Distinct idempotency keys must not be able to create competing holds for - // the same proposal. The normal idempotency store gives us a distributed, - // expiring put-if-absent claim across Fly machines; the claim is always - // released after the commit so a later retry can observe the committed - // proposal and return its original IO/expiry. - const sessionScope = sessionKeyFromArgs(args, ctx.mode, ctx.userId, ctx.moduleId); - const principal = `proposal-finalize-lock:${createHash('sha256').update(sessionScope).digest('hex')}`; + const req = args as unknown as GetProductsRequest & ToolArgs; + const paginationOffset = req.pagination + ? decodeOffsetCursor('products', req.pagination.cursor) + : undefined; + if (paginationOffset === null) { + return { + errors: [{ code: 'INVALID_REQUEST', message: 'pagination.cursor is malformed' }] as TaskError[], + }; + } + + // Session persistence is whole-document last-writer-wins, so every + // get_products execution must share one session-scoped mutex. Locking only + // finalized proposal IDs still lets a concurrent brief/wholesale request + // overwrite the committed proposal written by a finalization request. + const sessionScope = getProductsSessionKeyFromArgs(args, ctx.mode, ctx.userId, ctx.moduleId); + const sessionHash = createHash('sha256').update(sessionScope).digest('hex'); + const principal = 'get-products-session-mutex'; + const key = `get-products-session:${sessionHash}`; const store = getIdempotencyStore(); - const acquiredKeys: string[] = []; - for (const proposalId of proposalIds) { - const key = `proposal-finalize:${createHash('sha256').update(proposalId).digest('hex')}`; - const claim = await store.check({ principal, key, payload: { proposal_id: proposalId } }); - if (claim.kind !== 'miss') { - await Promise.all(acquiredKeys.map(acquiredKey => store.release({ principal, key: acquiredKey }))); - return { - errors: [{ - code: 'RATE_LIMITED', - message: 'A proposal finalization is already in progress. Retry after a short delay.', - recovery: 'transient', - }], - }; - } - acquiredKeys.push(key); + const claim = await store.check({ principal, key, payload: { session: sessionHash } }); + if (claim.kind !== 'miss') { + return { + errors: [{ + code: 'CONFLICT', + message: 'Another get_products request is already updating this session. Retry after a short delay.', + recovery: 'transient', + }], + }; } try { - const result = await handleGetProductsUnlocked(args, ctx); - const hasErrors = Array.isArray((result as { errors?: unknown }).errors) - && (result as { errors: unknown[] }).errors.length > 0; - const hasSuccessPayload = Array.isArray((result as { products?: unknown }).products) - || Array.isArray((result as { proposals?: unknown }).proposals); - if (!hasErrors || hasSuccessPayload) { - // Keep the proposal-level claim until the committed proposal is durable. - // Releasing immediately after the in-memory mutation would let a second - // process acquire the lock, reload the still-draft row, and mint a - // competing IO before the outer dispatcher reached its flush step. - await flushDirtySessions(); - } + const result = await handleGetProductsUnlocked(args, ctx, paginationOffset); + // Keep the mutex until every mutation made by discovery is durable. This + // includes context changes on non-finalize calls, not just proposal holds. + await flushDirtySessions(); return result; } finally { - await Promise.all(acquiredKeys.map(key => store.release({ principal, key }))); + await store.release({ principal, key, claimToken: claim.claimToken }); } } -async function handleGetProductsUnlocked(args: ToolArgs, ctx: TrainingContext): Promise { +async function handleGetProductsUnlocked( + args: ToolArgs, + ctx: TrainingContext, + paginationOffset?: number, +): Promise { const req = args as unknown as GetProductsRequest & ToolArgs; const buyingMode = req.buying_mode || 'brief'; const brief = (req as Record).brief; @@ -3908,7 +3915,12 @@ async function handleGetProductsUnlocked(args: ToolArgs, ctx: TrainingContext): }; } } - const session = await getSession(sessionKeyFromArgs(req, ctx.mode, ctx.userId, ctx.moduleId)); + const session = await getSession(getProductsSessionKeyFromArgs(req, ctx.mode, ctx.userId, ctx.moduleId)); + const committedProposals = new Map( + (session.lastGetProductsContext?.proposals ?? []) + .filter(proposal => proposalLifecycle(proposal).proposal_status === 'committed') + .map(proposal => [proposal.proposal_id, proposal]), + ); const wholesaleMeta = buyingMode === 'wholesale' ? productWholesaleFeedMeta(req as WholesaleFeedRequest, session) : undefined; @@ -4212,6 +4224,15 @@ async function handleGetProductsUnlocked(args: ToolArgs, ctx: TrainingContext): } else if (action === 'include') { explicitlySelectedProposals.set(proposal.proposal_id, proposal); for (const allocation of proposal.allocations) includeIds.add(allocation.product_id); + if (proposalLifecycle(proposal).proposal_status === 'committed' && op.ask) { + refinementApplied.push({ + scope: 'proposal', + proposal_id: op.proposal_id, + status: 'unable', + notes: 'Proposal is already committed and cannot be refined. Discover or select a draft proposal instead.', + }); + continue; + } const concreteCpmAsk = parseConcreteCpmAsk(op.ask); if (concreteCpmAsk) { const stagedProducts = new Map>(); @@ -4372,10 +4393,12 @@ async function handleGetProductsUnlocked(args: ToolArgs, ctx: TrainingContext): } } - // In refine mode, use session proposals (which may include finalized versions) + // In refine mode, use session proposals (which may include finalized + // versions). In other discovery modes, replace registry drafts with the + // exact committed object already held by this session. const contextualProposals = (buyingMode === 'refine' && session.lastGetProductsContext?.proposals) ? session.lastGetProductsContext.proposals - : getProposals(); + : getProposals().map(proposal => committedProposals.get(proposal.proposal_id) ?? proposal); const sourceProposals = [ ...contextualProposals, ...Array.from(explicitlySelectedProposals.values()).filter(selected => @@ -4390,18 +4413,23 @@ async function handleGetProductsUnlocked(args: ToolArgs, ctx: TrainingContext): proposal.allocations.every(a => productIds.has(a.product_id)) && !proposalOmitIds.has(proposal.proposal_id), ) - .map(proposal => ({ - ...proposal, - allocations: proposal.allocations.map(alloc => { - const pricingOptions = productsById.get(alloc.product_id)?.pricing_options; - const selectedPricing = pricingOptions?.find( - option => option.pricing_option_id === alloc.pricing_option_id, - ) ?? pricingOptions?.[0]; - return selectedPricing - ? { ...alloc, pricing_option_id: selectedPricing.pricing_option_id } - : alloc; - }), - })); + .map(proposal => { + // A committed proposal is a receipt for a specific inventory hold. + // Later catalog/pricing discovery must not rewrite any part of it. + if (proposalLifecycle(proposal).proposal_status === 'committed') return proposal; + return { + ...proposal, + allocations: proposal.allocations.map(alloc => { + const pricingOptions = productsById.get(alloc.product_id)?.pricing_options; + const selectedPricing = pricingOptions?.find( + option => option.pricing_option_id === alloc.pricing_option_id, + ) ?? pricingOptions?.[0]; + return selectedPricing + ? { ...alloc, pricing_option_id: selectedPricing.pricing_option_id } + : alloc; + }), + }; + }); const canonicalFormatAdvisories = collectCanonicalFormatAdvisories(products); const staleDirective = session.complyExtensions.forcedUpstreamUnavailable?.tool === 'get_products' ? session.complyExtensions.forcedUpstreamUnavailable @@ -4414,19 +4442,26 @@ async function handleGetProductsUnlocked(args: ToolArgs, ctx: TrainingContext): const responseProducts = isThreeZeroStoryboardCompat(ctx) ? products.map(productForThreeZeroStoryboardCompat) : products; + const retainedCommittedProposals = new Map( + (session.lastGetProductsContext?.proposals ?? []) + .filter(proposal => proposalLifecycle(proposal).proposal_status === 'committed') + .map(proposal => [proposal.proposal_id, proposal]), + ); + const persistedProposals = buyingMode === 'wholesale' ? [] : [...proposals]; + const persistedProposalIds = new Set(persistedProposals.map(proposal => proposal.proposal_id)); + for (const proposal of retainedCommittedProposals.values()) { + if (!persistedProposalIds.has(proposal.proposal_id)) persistedProposals.push(proposal); + } session.lastGetProductsContext = { products: responseProducts, - proposals: buyingMode === 'wholesale' ? [] : proposals, + proposals: persistedProposals, }; let pageProducts = responseProducts; let pagination: { has_more: boolean; total_count: number; cursor?: string } | undefined; if (req.pagination) { - const offset = decodeOffsetCursor('products', req.pagination.cursor); - if (offset === null) { - return { - errors: [{ code: 'INVALID_REQUEST', message: 'pagination.cursor is malformed' }] as TaskError[], - }; - } + // The exported handler validates this before acquiring the mutex or + // loading session state. + const offset = paginationOffset ?? 0; const maxResults = Math.min( typeof req.pagination.max_results === 'number' && req.pagination.max_results >= 1 ? req.pagination.max_results @@ -9685,6 +9720,9 @@ function validateIdempotencyProtectedInput( ...(field && { field }), }; } + if (parsed.data.pagination && decodeOffsetCursor('products', parsed.data.pagination.cursor) === null) { + return { message: 'pagination.cursor is malformed', field: 'pagination.cursor' }; + } // Finalization is a commit boundary, not another refinement. Reject mixed // arrays before the idempotency store is consulted so an invalid request @@ -9790,7 +9828,15 @@ async function executeTrainingAgentToolInContext( return { success: false, error: `Unknown tool: ${toolName}` }; } const authPrincipal = ctx.principal ?? ctx.userId ?? 'anonymous'; - const accountScope = deriveAccountScope(initialHandlerArgs); + let accountScope: string | undefined; + try { + accountScope = toolName === 'comply_test_controller' + ? undefined + : deriveAccountScope(initialHandlerArgs, toolName === 'get_products'); + } catch (error) { + if (!(error instanceof AccountRefValidationError)) throw error; + return { success: false, error: `Invalid ${toolName} request at account: ${error.message}` }; + } const principal = scopedPrincipal(authPrincipal, accountScope); const handlerArgs = applyThreeZeroGetProductsIdempotencyCompatibility( toolName, @@ -9799,7 +9845,7 @@ async function executeTrainingAgentToolInContext( ctx.storyboardCompat?.version === '3.0' || initialHandlerArgs.adcp_version === '3.0', ); const idempotencyKey = handlerArgs.idempotency_key; - let claim: { payloadHash: string } | undefined; + let claim: { payloadHash: string; claimToken: string } | undefined; if (isMutatingTool(toolName)) { if (idempotencyKey === undefined || idempotencyKey === null) { @@ -9834,9 +9880,12 @@ async function executeTrainingAgentToolInContext( return { success: false, error: 'IDEMPOTENCY_CONFLICT' }; } if (outcome.kind === 'in-flight') { - return { success: false, error: 'RATE_LIMITED: matching request is already in progress' }; + return { + success: false, + error: `IDEMPOTENCY_IN_FLIGHT: matching request is already in progress; retry_after=${outcome.retryAfterSeconds}`, + }; } - claim = { payloadHash: outcome.payloadHash }; + claim = { payloadHash: outcome.payloadHash, claimToken: outcome.claimToken }; } try { const result = await Promise.resolve(handler( @@ -9849,21 +9898,34 @@ async function executeTrainingAgentToolInContext( const hasErrors = Array.isArray(cacheResponse.errors) && cacheResponse.errors.length > 0; const hasAdvisorySuccessPayload = permitsAdvisoryErrors(toolName, cacheResponse); if (hasErrors && !hasAdvisorySuccessPayload) { - await getIdempotencyStore().release({ principal, key: idempotencyKey }); + await getIdempotencyStore().release({ + principal, + key: idempotencyKey, + claimToken: claim.claimToken, + }); } else { - await flushDirtySessions(); + // get_products finalization state must be durable before its replay is + // published. Other tools retain the historical save-then-flush order; + // moving every synchronous mutation to flush-first creates a new + // duplicate-execution window if cache publication fails. + if (toolName === 'get_products') await flushDirtySessions(); await getIdempotencyStore().save({ principal, key: idempotencyKey, payloadHash: claim.payloadHash, response: cacheResponse, + claimToken: claim.claimToken, }); } } return { success: true, data: response }; } catch (error) { if (claim && typeof idempotencyKey === 'string') { - await getIdempotencyStore().release({ principal, key: idempotencyKey }); + await getIdempotencyStore().release({ + principal, + key: idempotencyKey, + claimToken: claim.claimToken, + }); } logger.error({ error, tool: toolName }, 'Training agent in-process tool error'); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; @@ -9969,7 +10031,22 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { // callers can already enumerate their own account's keys — so the // scoping adds no useful probing surface while closing the cross-caller // leak. - const accountScope = deriveAccountScope(initialHandlerArgs); + let accountScope: string | undefined; + try { + accountScope = name === 'comply_test_controller' + ? undefined + : deriveAccountScope(initialHandlerArgs, name === 'get_products'); + } catch (error) { + if (!(error instanceof AccountRefValidationError)) throw error; + return { + result: adcpError('INVALID_REQUEST', { + message: error.message, + field: error.field, + recovery: 'correctable', + }, callerContext, servedAdcpVersion), + flushable: true, + }; + } const idempotencyPrincipal = scopedPrincipal(authPrincipal, accountScope); const handlerArgs = applyThreeZeroGetProductsIdempotencyCompatibility( name, @@ -9985,6 +10062,7 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { let skipHandler = false; let idempotencyPayloadHash: string | undefined; let idempotencyClaimed = false; + let idempotencyClaimToken: string | undefined; let idempotencyReplayed = false; let idempotencyReplayResponse: Record | undefined; @@ -10050,13 +10128,11 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { }; } if (outcome.kind === 'in-flight') { - // A parallel request with the same key is executing. Retries should - // back off and see 'replay' once the in-flight handler saves. Return - // a transient error so the caller retries after a brief delay. return { - result: adcpError('RATE_LIMITED', { + result: adcpError('IDEMPOTENCY_IN_FLIGHT', { message: 'A concurrent request with this idempotency_key is already in progress. Retry after a short delay.', recovery: 'transient', + retry_after: outcome.retryAfterSeconds, }, callerContext, servedAdcpVersion), flushable: true, }; @@ -10086,6 +10162,7 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { // call save() on success or release() on any other path so the // placeholder doesn't leak. idempotencyPayloadHash = outcome.payloadHash; + idempotencyClaimToken = outcome.claimToken; idempotencyClaimed = true; // A previous task execution may have durably stored both its domain @@ -10104,46 +10181,59 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { const recoveredTask = await getIdempotentTask(taskStore, naturalKey); if (recoveredTask) { if (recoveredTask.status !== 'completed') { - throw new Error(`Prior idempotent task ${recoveredTask.taskId} is not recoverable in status ${recoveredTask.status}`); - } - const recoveredResult = await taskStore.getTaskResult(recoveredTask.taskId) as CallToolResult; - const recoveredBody = isRecord(recoveredResult.structuredContent) - ? recoveredResult.structuredContent - : undefined; - if (recoveredResult.isError || !recoveredBody) { - throw new Error(`Prior idempotent task ${recoveredTask.taskId} has no successful structured result`); + // get_products commits converge on the exact stored proposal + // (including IO and expiry), so it is safe to repair an + // orphaned deterministic task by rerunning the handler and + // storing the result into the same task below. Other mutators + // may have non-reconstructable random IDs and must fail closed. + if (name !== 'get_products') { + throw new Error(`Prior idempotent task ${recoveredTask.taskId} is not recoverable in status ${recoveredTask.status}`); + } + } else { + const recoveredResult = await taskStore.getTaskResult(recoveredTask.taskId) as CallToolResult; + const recoveredBody = isRecord(recoveredResult.structuredContent) + ? recoveredResult.structuredContent + : undefined; + if (recoveredResult.isError || !recoveredBody) { + throw new Error(`Prior idempotent task ${recoveredTask.taskId} has no successful structured result`); + } + + const taskResponse = { task: recoveredTask, adcp_version: servedAdcpVersion }; + await store.save({ + principal: idempotencyPrincipal, + key: idempotencyKey, + payloadHash: idempotencyPayloadHash, + response: taskResponse, + claimToken: idempotencyClaimToken, + }); + const { + context: _cachedContext, + replayed: _cachedReplayMarker, + ...notificationResponse + } = recoveredBody; + maybeEmitCompletionWebhook({ + toolName: name, + args: handlerArgs, + response: notificationResponse, + requestIdempotencyKey: idempotencyKey, + principal: idempotencyPrincipal, + }); + return { + result: { + ...taskResponse, + replayed: true, + ...(callerContext !== undefined && { context: callerContext }), + }, + flushable: false, + }; } - - const taskResponse = { task: recoveredTask, adcp_version: servedAdcpVersion }; - await store.save({ - principal: idempotencyPrincipal, - key: idempotencyKey, - payloadHash: idempotencyPayloadHash, - response: taskResponse, - }); - const { - context: _cachedContext, - replayed: _cachedReplayMarker, - ...notificationResponse - } = recoveredBody; - maybeEmitCompletionWebhook({ - toolName: name, - args: handlerArgs, - response: notificationResponse, - requestIdempotencyKey: idempotencyKey, - principal: idempotencyPrincipal, - }); - return { - result: { - ...taskResponse, - replayed: true, - ...(callerContext !== undefined && { context: callerContext }), - }, - flushable: false, - }; } } catch (error) { - await store.release({ principal: idempotencyPrincipal, key: idempotencyKey }); + await store.release({ + principal: idempotencyPrincipal, + key: idempotencyKey, + claimToken: idempotencyClaimToken, + }); throw error; } } @@ -10254,24 +10344,38 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { const resolveIdempotencyClaim = async ( responseToCache: Record | null, ): Promise => { - if (!idempotencyClaimed || typeof idempotencyKey !== 'string') return false; + if ( + !idempotencyClaimed + || typeof idempotencyKey !== 'string' + || typeof idempotencyClaimToken !== 'string' + ) return false; const store = getIdempotencyStore(); const shouldSave = responseToCache !== null && !toolResult!.isError && !handlerThrew; if (!shouldSave || !idempotencyPayloadHash) { - await store.release({ principal: idempotencyPrincipal, key: idempotencyKey }); + await store.release({ + principal: idempotencyPrincipal, + key: idempotencyKey, + claimToken: idempotencyClaimToken, + }); return false; } try { - await flushDirtySessions(); + const flushedBeforeSave = name === 'get_products'; + if (flushedBeforeSave) await flushDirtySessions(); await store.save({ principal: idempotencyPrincipal, key: idempotencyKey, payloadHash: idempotencyPayloadHash, response: responseToCache, + claimToken: idempotencyClaimToken, }); - return true; + return flushedBeforeSave; } catch (error) { - await store.release({ principal: idempotencyPrincipal, key: idempotencyKey }); + await store.release({ + principal: idempotencyPrincipal, + key: idempotencyKey, + claimToken: idempotencyClaimToken, + }); throw error; } }; diff --git a/server/src/training-agent/tenants/custom-tool-helper.ts b/server/src/training-agent/tenants/custom-tool-helper.ts index 977c19aa19..5b351a6e1b 100644 --- a/server/src/training-agent/tenants/custom-tool-helper.ts +++ b/server/src/training-agent/tenants/custom-tool-helper.ts @@ -15,6 +15,7 @@ import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; import { createLogger } from '../../logger.js'; import { runWithSessionContext, flushDirtySessions } from '../state.js'; import type { ToolArgs, TrainingContext } from '../types.js'; +import { AccountRefValidationError, accountScopeFromRef } from '../account-scope.js'; import { getIdempotencyStore, scopedPrincipal, @@ -50,6 +51,7 @@ interface IdempotencyClaim { principal: string; key: string; payloadHash: string; + claimToken: string; } function toAdaptedResponse(result: unknown, callerContext: unknown, options: CustomToolOptions): AdaptedResponse { @@ -103,33 +105,32 @@ function serviceUnavailable(err: unknown, callerContext: unknown): AdaptedRespon type LegacyHandler = (args: ToolArgs, ctx: TrainingContext) => object | Promise; -function deriveAccountScope(params: Record): string | undefined { +export function deriveAccountScope(params: Record): string | undefined { const usageAccount = Array.isArray(params.usage) ? (params.usage[0] as { account?: unknown } | undefined)?.account : undefined; - const account = (params.account ?? usageAccount) as { account_id?: string; brand?: { domain?: string } } | undefined; - if (account?.account_id && typeof account.account_id === 'string') { - return `a:${account.account_id}`; - } - const domain = account?.brand?.domain - ?? (params.brand as { domain?: string } | undefined)?.domain; - if (typeof domain === 'string' && domain.length > 0) { - return `b:${domain.toLowerCase()}`; - } - return undefined; + const account = Object.prototype.hasOwnProperty.call(params, 'account') + ? params.account + : usageAccount; + if (account !== undefined) return accountScopeFromRef(account); + const domain = (params.brand as { domain?: unknown } | undefined)?.domain; + return typeof domain === 'string' && domain.length > 0 + ? `b:${domain.toLowerCase()}` + : undefined; } function idempotencyError( code: string, message: string, callerContext: unknown, - options: { field?: string; recovery?: string } = {}, + options: { field?: string; recovery?: string; retry_after?: number } = {}, ): AdaptedResponse { const errorObj: Record = { code, message, ...(options.field && { field: options.field }), ...(options.recovery && { recovery: options.recovery }), + ...(options.retry_after !== undefined && { retry_after: options.retry_after }), }; const body = wrapEnvelope({ adcp_error: errorObj }, { context: callerContext }); return { @@ -144,6 +145,7 @@ async function releaseClaim(claim: IdempotencyClaim | null): Promise { await getIdempotencyStore().release({ principal: claim.principal, key: claim.key, + claimToken: claim.claimToken, }); } @@ -193,10 +195,19 @@ export function customToolFor( { field: 'idempotency_key', recovery: 'correctable' }, ); } - const principal = scopedPrincipal( - trainingCtx.principal ?? 'anonymous', - deriveAccountScope(handlerArgs), - ); + let accountScope: string | undefined; + try { + accountScope = deriveAccountScope(handlerArgs); + } catch (err) { + if (!(err instanceof AccountRefValidationError)) throw err; + return idempotencyError( + 'INVALID_REQUEST', + err.message, + callerContext, + { field: err.field, recovery: 'correctable' }, + ); + } + const principal = scopedPrincipal(trainingCtx.principal ?? 'anonymous', accountScope); const outcome = await getIdempotencyStore().check({ principal, key: idempotencyKey, @@ -219,10 +230,10 @@ export function customToolFor( } if (outcome.kind === 'in-flight') { return idempotencyError( - 'RATE_LIMITED', + 'IDEMPOTENCY_IN_FLIGHT', 'A concurrent request with this idempotency_key is already in progress. Retry after a short delay.', callerContext, - { recovery: 'transient' }, + { recovery: 'transient', retry_after: outcome.retryAfterSeconds }, ); } if (outcome.kind === 'replay') { @@ -234,6 +245,7 @@ export function customToolFor( principal, key: idempotencyKey, payloadHash: outcome.payloadHash, + claimToken: outcome.claimToken, }; } let result: unknown; @@ -261,6 +273,7 @@ export function customToolFor( key: claim.key, payloadHash: claim.payloadHash, response: result as Record, + claimToken: claim.claimToken, }); } else { await releaseClaim(claim); diff --git a/server/src/training-agent/tenants/registry.ts b/server/src/training-agent/tenants/registry.ts index 86fa78a123..667da44cba 100644 --- a/server/src/training-agent/tenants/registry.ts +++ b/server/src/training-agent/tenants/registry.ts @@ -36,7 +36,7 @@ import { type CreateAdcpServerFromPlatformOptions, } from '@adcp/sdk/server'; import { getPool } from '../../db/client.js'; -import { getIdempotencyStore, scopedPrincipal } from '../idempotency.js'; +import { getSdkIdempotencyStore, scopedPrincipal } from '../idempotency.js'; import { emitFrameworkTaskWebhook, getWebhookSigningMaterial } from '../webhooks.js'; import { isWebhookTestOrDevelopment } from '../webhook-fetch.js'; import { buildSignalsTenantConfig } from './signals.js'; @@ -184,7 +184,7 @@ function buildDefaultServerOptions(storyboardCompat?: TrainingContext['storyboar name: 'adcp-training-agent', version: '1.0.0', ...(storyboardCompat?.version === '3.0' && { adcpVersion: '3.0' }), - idempotency: getIdempotencyStore(), + idempotency: getSdkIdempotencyStore(), webhooks: getWebhookSigningMaterial(), taskWebhookEmitter: { emit: emitFrameworkTaskWebhook, diff --git a/server/src/training-agent/tenants/router.ts b/server/src/training-agent/tenants/router.ts index 6c45d15b22..4b64e741e1 100644 --- a/server/src/training-agent/tenants/router.ts +++ b/server/src/training-agent/tenants/router.ts @@ -21,11 +21,65 @@ import { handleComplyTestController } from '../comply-test-controller.js'; import { adcpError, resolveServedAdcpVersion, supportedCanonicalFormatsCapability } from '../task-handlers.js'; import { GET_PRODUCTS_REJECTED_ADCP_VERSION, type TrainingContext } from '../types.js'; import { getAgentUrl } from '../config.js'; +import { redactConflictEnvelopeInBody } from '../conflict-envelope.js'; const logger = createLogger('training-agent-tenant-router'); const PRODUCT_WHOLESALE_EVENTS = ['product.created', 'product.updated', 'product.priced', 'product.removed'] as const; const SIGNAL_WHOLESALE_EVENTS = ['signal.created', 'signal.updated', 'signal.priced', 'signal.removed'] as const; +function installConflictEnvelopeRedaction(res: Response): void { + const originalWriteHead = res.writeHead.bind(res); + const originalWrite = res.write.bind(res); + const originalEnd = res.end.bind(res); + let bufferedHead: unknown[] | undefined; + const chunks: Buffer[] = []; + const toBuffer = (chunk: unknown, encoding?: BufferEncoding): Buffer => { + if (Buffer.isBuffer(chunk)) return chunk; + if (typeof chunk === 'string') return Buffer.from(chunk, encoding); + if (chunk instanceof Uint8Array) return Buffer.from(chunk); + return Buffer.from(String(chunk), encoding); + }; + + res.writeHead = ((...args: unknown[]) => { + const headers = (typeof args[1] === 'string' ? args[2] : args[1]) as Record | undefined; + const contentType = Object.entries(headers ?? {}).find(([key]) => key.toLowerCase() === 'content-type')?.[1]; + if (typeof contentType === 'string' && contentType.includes('application/json')) { + const clonedHeaders = { ...headers }; + bufferedHead = typeof args[1] === 'string' + ? [args[0], args[1], clonedHeaders] + : [args[0], clonedHeaders]; + return res; + } + return Reflect.apply(originalWriteHead, res, args); + }) as typeof res.writeHead; + + res.write = ((chunk: unknown, ...args: unknown[]) => { + if (!bufferedHead) return Reflect.apply(originalWrite, res, [chunk, ...args]); + const encoding = typeof args[0] === 'string' ? args[0] as BufferEncoding : undefined; + chunks.push(toBuffer(chunk, encoding)); + const callback = args.find(value => typeof value === 'function') as (() => void) | undefined; + callback?.(); + return true; + }) as typeof res.write; + + res.end = ((chunk?: unknown, ...args: unknown[]) => { + if (!bufferedHead) return Reflect.apply(originalEnd, res, [chunk, ...args]); + if (chunk !== undefined && chunk !== null) { + const encoding = typeof args[0] === 'string' ? args[0] as BufferEncoding : undefined; + chunks.push(toBuffer(chunk, encoding)); + } + const rewritten = redactConflictEnvelopeInBody(Buffer.concat(chunks).toString('utf8')); + const headersIndex = typeof bufferedHead[1] === 'string' ? 2 : 1; + const headers = bufferedHead[headersIndex] as Record; + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === 'content-length') delete headers[key]; + } + headers['content-length'] = Buffer.byteLength(rewritten); + Reflect.apply(originalWriteHead, res, bufferedHead); + return Reflect.apply(originalEnd, res, [rewritten, ...args]); + }) as typeof res.end; +} + const SALES_LEGACY_CAPABILITY_SCENARIOS = [ 'force_creative_status', 'force_media_buy_status', @@ -311,6 +365,7 @@ function tenantMcpHandler(holder: RegistryHolder, tenantId: string, storyboardCo try { await resolved.server.connect(transport); logger.debug({ tenantId: resolved.tenantId, method: req.body?.method }, 'tenant MCP request'); + installConflictEnvelopeRedaction(res); await runWithSessionContext(async () => { await transport.handleRequest(req, res, req.body); await flushDirtySessions(); @@ -502,23 +557,29 @@ function projectTenantToolDiscovery( const required = Array.isArray(inputSchema.required) ? inputSchema.required.filter((value): value is string => typeof value === 'string') : []; + const properties = { ...(inputSchema.properties ?? {}) }; + const isThreeZeroCompat = storyboardCompat?.version === '3.0'; + if (isThreeZeroCompat) { + delete properties.idempotency_key; + } else { + properties.idempotency_key = { + type: 'string', + minLength: 16, + maxLength: 255, + pattern: '^[A-Za-z0-9_.:-]{16,255}$', + description: 'Client-generated key for this logical request. Reuse it unchanged for retries.', + }; + } getProducts.inputSchema = { ...inputSchema, - properties: { - ...(inputSchema.properties ?? {}), - idempotency_key: { - type: 'string', - minLength: 16, - maxLength: 255, - pattern: '^[A-Za-z0-9_.:-]{16,255}$', - description: 'Client-generated key for this logical request. Reuse it unchanged for retries.', - }, - }, - required: [...new Set([...required, 'idempotency_key'])], + properties, + required: isThreeZeroCompat + ? required.filter(field => field !== 'idempotency_key') + : [...new Set([...required, 'idempotency_key'])], }; getProducts.annotations = { ...(getProducts.annotations ?? {}), - readOnlyHint: false, + readOnlyHint: isThreeZeroCompat, idempotentHint: true, }; } diff --git a/server/src/training-agent/tenants/tenant-smoke.test.ts b/server/src/training-agent/tenants/tenant-smoke.test.ts index 2e3ba33f48..e1517bec28 100644 --- a/server/src/training-agent/tenants/tenant-smoke.test.ts +++ b/server/src/training-agent/tenants/tenant-smoke.test.ts @@ -897,8 +897,13 @@ describe('tenant routing smoke', () => { ...payload, buying_mode: 'brief', brief: 'different logical request', - }) as { result?: { structuredContent?: { adcp_error?: { code?: string } } } }; - expect(conflict.result?.structuredContent?.adcp_error?.code).toBe('IDEMPOTENCY_CONFLICT'); + }) as { result?: { structuredContent?: { adcp_error?: Record } } }; + const conflictEnvelope = conflict.result?.structuredContent?.adcp_error; + expect(conflictEnvelope?.code).toBe('IDEMPOTENCY_CONFLICT'); + expect(conflictEnvelope).not.toHaveProperty('recovery'); + expect(Object.keys(conflictEnvelope ?? {}).every(key => [ + 'code', 'message', 'status', 'retry_after', 'correlation_id', 'request_id', 'operation_id', + ].includes(key))).toBe(true); } finally { await close(); } @@ -909,15 +914,41 @@ describe('tenant routing smoke', () => { try { const url = `${baseUrl}/sales/mcp`; await initializeTenant(url); + const listResponse = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + authorization: 'Bearer test-token', + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), + }); + const listBody = await listResponse.json() as { + result?: { + tools?: Array<{ + name?: string; + inputSchema?: { + properties?: Record; + required?: string[]; + }; + annotations?: Record; + }>; + }; + }; + const discovered = listBody.result?.tools?.find(tool => tool.name === 'get_products'); + expect(discovered?.inputSchema?.properties).not.toHaveProperty('idempotency_key'); + expect(discovered?.inputSchema?.required).not.toContain('idempotency_key'); + expect(discovered?.annotations).toMatchObject({ readOnlyHint: true, idempotentHint: true }); + const account = { brand: { domain: 'tenant-products-legacy.example' }, operator: 'tenant-products-legacy.example', }; const payload = { buying_mode: 'wholesale', account }; - const first = await callTenantTool(url, 2, 'get_products', payload) as { + const first = await callTenantTool(url, 3, 'get_products', payload) as { result?: { structuredContent?: { products?: unknown[]; replayed?: boolean } }; }; - const replay = await callTenantTool(url, 3, 'get_products', payload) as { + const replay = await callTenantTool(url, 4, 'get_products', payload) as { result?: { structuredContent?: { products?: unknown[]; replayed?: boolean } }; }; expect(first.result?.structuredContent?.products?.length).toBeGreaterThan(0); @@ -925,7 +956,7 @@ describe('tenant routing smoke', () => { expect(replay.result?.structuredContent?.products).toEqual(first.result?.structuredContent?.products); expect(replay.result?.structuredContent?.replayed).toBe(true); - const changed = await callTenantTool(url, 4, 'get_products', { + const changed = await callTenantTool(url, 5, 'get_products', { buying_mode: 'brief', brief: 'A different frozen 3.0 request', account, diff --git a/server/src/training-agent/v6-sales-platform.ts b/server/src/training-agent/v6-sales-platform.ts index 11a6c53991..0f7aaf9216 100644 --- a/server/src/training-agent/v6-sales-platform.ts +++ b/server/src/training-agent/v6-sales-platform.ts @@ -30,6 +30,7 @@ import { handleListCreatives, handleListCreativeFormats, hasAdcpSuccessPayload, + resolveServedAdcpVersion, } from './task-handlers.js'; import { handleProvidePerformanceFeedback, @@ -192,18 +193,24 @@ function throwGetProductsExecutionError(message: string): never { ? 'IDEMPOTENCY_CONFLICT' : message.includes('IDEMPOTENCY_EXPIRED') ? 'IDEMPOTENCY_EXPIRED' - : message.includes('RATE_LIMITED') - ? 'RATE_LIMITED' - : invalidRequest - ? 'INVALID_REQUEST' - : 'SERVICE_UNAVAILABLE'; + : message.includes('IDEMPOTENCY_IN_FLIGHT') + ? 'IDEMPOTENCY_IN_FLIGHT' + : message.includes('RATE_LIMITED') + ? 'RATE_LIMITED' + : invalidRequest + ? 'INVALID_REQUEST' + : 'SERVICE_UNAVAILABLE'; const field = validationMatch?.[1] ?? (message.startsWith('brief must be a string') ? 'brief' : undefined) ?? (message.includes('idempotency_key') ? 'idempotency_key' : undefined); + const retryAfterMatch = message.match(/retry_after=(\d+)/); throw new AdcpError(code, { - recovery: code === 'RATE_LIMITED' || code === 'SERVICE_UNAVAILABLE' ? 'transient' : 'correctable', + recovery: code === 'RATE_LIMITED' || code === 'IDEMPOTENCY_IN_FLIGHT' || code === 'SERVICE_UNAVAILABLE' + ? 'transient' + : 'correctable', message, ...(code === 'INVALID_REQUEST' && field && { field }), + ...(retryAfterMatch && { retry_after: Number(retryAfterMatch[1]) }), }); } @@ -277,10 +284,20 @@ export class TrainingSalesPlatform // mutating the SDK's global task classification: validation, session // durability, and replay publication then share the same ordering as v5 // and direct Addie dispatch. + const versionResolution = resolveServedAdcpVersion(req as unknown as Record); + if (!versionResolution.ok) { + throw new AdcpError('VERSION_UNSUPPORTED', { + message: versionResolution.message, + field: versionResolution.field, + details: versionResolution.details as unknown as Record, + }); + } + const trainingCtx = buildTrainingCtx(ctx, this.storyboardCompat); + trainingCtx.servedAdcpVersion = versionResolution.servedVersion; const executed = await executeTrainingAgentTool( 'get_products', req as ToolArgs, - buildTrainingCtx(ctx, this.storyboardCompat), + trainingCtx, ); if (!executed.success) throwGetProductsExecutionError(executed.error ?? 'get_products failed'); return translateV5Result(executed.data, { allowAdvisories: true }); diff --git a/server/tests/unit/comply-test-controller.test.ts b/server/tests/unit/comply-test-controller.test.ts index ba4d7295a7..016c71c3e8 100644 --- a/server/tests/unit/comply-test-controller.test.ts +++ b/server/tests/unit/comply-test-controller.test.ts @@ -1347,7 +1347,7 @@ describe('comply_test_controller', () => { it('blocks create_media_buy when account is suspended', async () => { const accountId = 'acct-gated'; - const ACCT_WITH_ID = { ...ACCOUNT, account_id: accountId }; + const ACCT_WITH_ID = { account_id: accountId }; // Suspend the account await simulateCallTool(server, 'comply_test_controller', { diff --git a/server/tests/unit/idempotency-store.test.ts b/server/tests/unit/idempotency-store.test.ts new file mode 100644 index 0000000000..a3e33d7069 --- /dev/null +++ b/server/tests/unit/idempotency-store.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + hashPayload, + memoryBackend, + type IdempotencyBackend, +} from '@adcp/sdk/server'; +import { + adaptOwnedIdempotencyStoreForSdk, + createHashAwareIdempotencyStore, +} from '../../src/training-agent/idempotency.js'; + +const PRINCIPAL = 'store-test-principal'; +const KEY = 'store-test-key-0001'; +const SCOPED_KEY = `${PRINCIPAL}\u001F${KEY}`; + +describe('hash-aware training-agent idempotency store', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('atomically stores the canonical hash and reports a matching concurrent claim as in-flight', async () => { + const backend = memoryBackend({ sweepIntervalMs: 0 }); + const store = createHashAwareIdempotencyStore({ backend }); + const payload = { budget: 5_000, idempotency_key: KEY }; + + const results = await Promise.all([ + store.check({ principal: PRINCIPAL, key: KEY, payload }), + store.check({ principal: PRINCIPAL, key: KEY, payload: { ...payload } }), + ]); + + expect(results.filter(result => result.kind === 'miss')).toHaveLength(1); + const inFlight = results.find(result => result.kind === 'in-flight'); + expect(inFlight).toMatchObject({ kind: 'in-flight', retryAfterSeconds: 30 }); + expect(await backend.get(SCOPED_KEY)).toMatchObject({ + payloadHash: hashPayload(payload), + response: { __adcp_pending_owner: expect.any(String) }, + }); + + await store.close(); + }); + + it('reports a different concurrent payload as a conflict instead of in-flight', async () => { + const backend = memoryBackend({ sweepIntervalMs: 0 }); + const store = createHashAwareIdempotencyStore({ backend }); + + const results = await Promise.all([ + store.check({ principal: PRINCIPAL, key: KEY, payload: { budget: 5_000 } }), + store.check({ principal: PRINCIPAL, key: KEY, payload: { budget: 25_000 } }), + ]); + + expect(results.map(result => result.kind).sort()).toEqual(['conflict', 'miss']); + await store.close(); + }); + + it('preserves expiry skew and the save, release, and transient-error lifecycle', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-09T12:00:00Z')); + const backend = memoryBackend({ sweepIntervalMs: 0 }); + const store = createHashAwareIdempotencyStore({ + backend, + ttlSeconds: 3_600, + clockSkewSeconds: 60, + }); + const payload = { operation: 'create' }; + + const claim = await store.check({ principal: PRINCIPAL, key: KEY, payload }); + expect(claim.kind).toBe('miss'); + if (claim.kind !== 'miss') throw new Error('expected the first check to claim the key'); + + await store.save({ + principal: PRINCIPAL, + key: KEY, + payloadHash: claim.payloadHash, + response: { resource_id: 'resource-1' }, + claimToken: claim.claimToken, + }); + await vi.advanceTimersByTimeAsync(3_660_000); + expect(await store.check({ principal: PRINCIPAL, key: KEY, payload })).toMatchObject({ + kind: 'replay', + response: { resource_id: 'resource-1' }, + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(await store.check({ principal: PRINCIPAL, key: KEY, payload })).toEqual({ kind: 'expired' }); + + const releaseKey = `${KEY}-release`; + const releasable = await store.check({ principal: PRINCIPAL, key: releaseKey, payload }); + expect(releasable.kind).toBe('miss'); + if (releasable.kind !== 'miss') throw new Error('expected releasable claim'); + await store.release({ principal: PRINCIPAL, key: releaseKey, claimToken: releasable.claimToken }); + const reclaimed = await store.check({ principal: PRINCIPAL, key: releaseKey, payload }); + expect(reclaimed.kind).toBe('miss'); + if (reclaimed.kind !== 'miss') throw new Error('expected release to make the key claimable'); + + await store.saveTransientError!({ + principal: PRINCIPAL, + key: releaseKey, + payloadHash: reclaimed.payloadHash, + response: { adcp_error: { code: 'VALIDATION_ERROR' } }, + claimToken: reclaimed.claimToken, + }); + expect(await store.check({ principal: PRINCIPAL, key: releaseKey, payload })).toMatchObject({ + kind: 'replay', + response: { adcp_error: { code: 'VALIDATION_ERROR' } }, + }); + await vi.advanceTimersByTimeAsync(71_000); + expect(await store.check({ principal: PRINCIPAL, key: releaseKey, payload })).toEqual({ kind: 'expired' }); + + await store.close(); + }); + + it('preserves capability and delegates probe, clearAll, and close to the backend', async () => { + const memory = memoryBackend({ sweepIntervalMs: 0 }); + const calls: string[] = []; + const backend: IdempotencyBackend = { + ...memory, + async probe() { + calls.push('probe'); + }, + async clearAll() { + calls.push('clearAll'); + await memory.clearAll!(); + }, + async close() { + calls.push('close'); + await memory.close!(); + }, + }; + const store = createHashAwareIdempotencyStore({ backend, ttlSeconds: 7_200 }); + + expect(store.capability()).toEqual({ replay_ttl_seconds: 7_200 }); + await store.probe!(); + await store.clearAll!(); + await store.close(); + expect(calls).toEqual(['probe', 'clearAll', 'close']); + }); + + it('fences a stale owner after an expired claim is taken over', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-09T12:00:00Z')); + const backend = memoryBackend({ sweepIntervalMs: 0 }); + const store = createHashAwareIdempotencyStore({ backend }); + const payload = { operation: 'finalize' }; + + const first = await store.check({ principal: PRINCIPAL, key: KEY, payload }); + expect(first.kind).toBe('miss'); + if (first.kind !== 'miss') throw new Error('expected initial claim'); + + await vi.advanceTimersByTimeAsync(181_000); + const successor = await store.check({ principal: PRINCIPAL, key: KEY, payload }); + expect(successor.kind).toBe('miss'); + if (successor.kind !== 'miss') throw new Error('expected successor claim'); + + await expect(store.save({ + principal: PRINCIPAL, + key: KEY, + payloadHash: first.payloadHash, + response: { resource_id: 'stale' }, + claimToken: first.claimToken, + })).rejects.toThrow('claim ownership was lost'); + await store.release({ principal: PRINCIPAL, key: KEY, claimToken: first.claimToken }); + + await store.save({ + principal: PRINCIPAL, + key: KEY, + payloadHash: successor.payloadHash, + response: { resource_id: 'winner' }, + claimToken: successor.claimToken, + }); + expect(await store.check({ principal: PRINCIPAL, key: KEY, payload })).toMatchObject({ + kind: 'replay', + response: { resource_id: 'winner' }, + }); + + await store.close(); + }); + + it('serializes fallback release with an expired takeover', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-09T12:00:00Z')); + const memory = memoryBackend({ sweepIntervalMs: 0 }); + let pauseNextGet = false; + let releaseRead!: () => void; + const readStarted = new Promise(resolve => { releaseRead = resolve; }); + let resumeRead!: () => void; + const readCanFinish = new Promise(resolve => { resumeRead = resolve; }); + const backend: IdempotencyBackend = { + ...memory, + async get(key) { + const entry = await memory.get(key); + if (pauseNextGet) { + pauseNextGet = false; + releaseRead(); + await readCanFinish; + } + return entry; + }, + }; + const store = createHashAwareIdempotencyStore({ backend }); + const payload = { operation: 'fallback-race' }; + const first = await store.check({ principal: PRINCIPAL, key: KEY, payload }); + if (first.kind !== 'miss') throw new Error('expected initial claim'); + await vi.advanceTimersByTimeAsync(181_000); + + pauseNextGet = true; + const releasing = store.release({ + principal: PRINCIPAL, + key: KEY, + claimToken: first.claimToken, + }); + await readStarted; + let takeoverFinished = false; + const takeover = store.check({ principal: PRINCIPAL, key: KEY, payload }).then(result => { + takeoverFinished = true; + return result; + }); + await Promise.resolve(); + expect(takeoverFinished).toBe(false); + + resumeRead(); + await releasing; + const successor = await takeover; + expect(successor.kind).toBe('miss'); + if (successor.kind !== 'miss') throw new Error('expected successor claim'); + expect(await backend.get(SCOPED_KEY)).toMatchObject({ + response: { __adcp_pending_owner: successor.claimToken }, + }); + await store.release({ principal: PRINCIPAL, key: KEY, claimToken: successor.claimToken }); + await store.close(); + }); + + it('lets the SDK adapter release the claim owned by its async request context', async () => { + const backend = memoryBackend({ sweepIntervalMs: 0 }); + const owned = createHashAwareIdempotencyStore({ backend }); + const adapter = adaptOwnedIdempotencyStoreForSdk(owned); + const payload = { operation: 'adapter-release' }; + + const first = await adapter.check({ principal: PRINCIPAL, key: KEY, payload }); + expect(first.kind).toBe('miss'); + await adapter.release({ principal: PRINCIPAL, key: KEY }); + + const reclaimed = await owned.check({ principal: PRINCIPAL, key: KEY, payload }); + expect(reclaimed.kind).toBe('miss'); + if (reclaimed.kind === 'miss') { + await owned.release({ principal: PRINCIPAL, key: KEY, claimToken: reclaimed.claimToken }); + } + await owned.close(); + }); +}); diff --git a/server/tests/unit/idempotency.test.ts b/server/tests/unit/idempotency.test.ts index 61f8ccf7d8..170a6c7714 100644 --- a/server/tests/unit/idempotency.test.ts +++ b/server/tests/unit/idempotency.test.ts @@ -158,6 +158,7 @@ describe('idempotency facade', () => { key: 'shared-key-uuuuu01', payloadHash: firstCheck.payloadHash, response: { id: 'acme' }, + claimToken: firstCheck.claimToken, }); } @@ -165,7 +166,11 @@ describe('idempotency facade', () => { const otherAccount = await store.check({ principal: b, key: 'shared-key-uuuuu01', payload }); expect(otherAccount.kind).toBe('miss'); if (otherAccount.kind === 'miss') { - await store.release({ principal: b, key: 'shared-key-uuuuu01' }); + await store.release({ + principal: b, + key: 'shared-key-uuuuu01', + claimToken: otherAccount.claimToken, + }); } // Same key under account A replays. diff --git a/server/tests/unit/training-agent-account-scope.test.ts b/server/tests/unit/training-agent-account-scope.test.ts new file mode 100644 index 0000000000..ff63134f32 --- /dev/null +++ b/server/tests/unit/training-agent-account-scope.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { + AccountRefValidationError, + accountScopeFromRef, + canonicalizeAccountRef, +} from '../../src/training-agent/account-scope.js'; +import { getProductsSessionKeyFromArgs, sessionKeyFromArgs } from '../../src/training-agent/state.js'; +import { + customToolFor, + deriveAccountScope, +} from '../../src/training-agent/tenants/custom-tool-helper.js'; + +describe('canonical AccountRef scope', () => { + it('uses the account-id namespace verbatim', () => { + expect(accountScopeFromRef({ account_id: 'acct_123' })).toBe('a:acct_123'); + }); + + it('normalizes the natural identity and defaults sandbox to false', () => { + expect(canonicalizeAccountRef({ + brand: { domain: 'House.Example', brand_id: 'spark' }, + operator: 'Pinnacle.Example', + })).toEqual({ + kind: 'natural', + brand: { domain: 'house.example', brand_id: 'spark' }, + operator: 'pinnacle.example', + sandbox: false, + }); + expect(accountScopeFromRef({ + brand: { domain: 'House.Example', brand_id: 'spark' }, + operator: 'Pinnacle.Example', + })).toBe('n:house.example:spark:pinnacle.example:0'); + }); + + it('partitions every natural-key discriminator', () => { + const base = { brand: { domain: 'house.example' }, operator: 'one.example' }; + const scopes = new Set([ + accountScopeFromRef(base), + accountScopeFromRef({ ...base, operator: 'two.example' }), + accountScopeFromRef({ ...base, sandbox: true }), + accountScopeFromRef({ ...base, brand: { ...base.brand, brand_id: 'spark' } }), + ]); + expect(scopes.size).toBe(4); + }); + + it.each([ + [{}, 'exactly one identity'], + [{ account_id: 'acct_123', brand: { domain: 'house.example' }, operator: 'one.example' }, 'exactly one identity'], + [{ account_id: 'acct_123', sandbox: false }, 'exactly one identity'], + [{ brand: { domain: 'house.example' } }, 'exactly one identity'], + [{ brand: { domain: 'house.example' }, operator: 'one.example', unexpected: true }, "field 'unexpected'"], + [{ account_id: 'acct_123', unexpected: true }, "field 'unexpected'"], + ])('rejects invalid closed-union shape %#', (value, message) => { + expect(() => canonicalizeAccountRef(value)).toThrow(AccountRefValidationError); + expect(() => canonicalizeAccountRef(value)).toThrow(message); + }); +}); + +describe('canonical session scope', () => { + it('uses the complete account identity without requiring a principal', () => { + expect(getProductsSessionKeyFromArgs({ + account: { + brand: { domain: 'House.Example', brand_id: 'spark' }, + operator: 'Pinnacle.Example', + sandbox: true, + }, + }, 'open')).toBe('open:n:house.example:spark:pinnacle.example:1'); + expect(getProductsSessionKeyFromArgs({ account: { account_id: 'acct_123' } }, 'open')) + .toBe('open:a:acct_123'); + expect(getProductsSessionKeyFromArgs({ + account: { brand: { domain: 'house.example' }, operator: 'house.example' }, + }, 'open')).toBe('open:house.example'); + }); + + it('keeps account-id identities disjoint from fallback and natural-account namespaces', () => { + expect(getProductsSessionKeyFromArgs({ account: { account_id: 'default' } }, 'open')) + .not.toBe(getProductsSessionKeyFromArgs({}, 'open')); + expect(getProductsSessionKeyFromArgs({ account: { account_id: 'house.example' } }, 'open')) + .not.toBe(getProductsSessionKeyFromArgs({ + account: { brand: { domain: 'house.example' }, operator: 'house.example' }, + }, 'open')); + }); + + it('uses hashed principal plus the complete canonical account scope when supplied', () => { + const base = { + account: { + brand: { domain: 'house.example' }, + operator: 'one.example', + }, + }; + const first = sessionKeyFromArgs(base, 'open', undefined, undefined, 'workos:org_one'); + const same = sessionKeyFromArgs(base, 'open', undefined, undefined, 'workos:org_one'); + const otherPrincipal = sessionKeyFromArgs(base, 'open', undefined, undefined, 'workos:org_two'); + const otherOperator = sessionKeyFromArgs({ + account: { ...base.account, operator: 'two.example' }, + }, 'open', undefined, undefined, 'workos:org_one'); + + expect(first).toBe(same); + expect(first).toMatch(/^open:p:[a-f0-9]{64}:n:house\.example:-:one\.example:0$/); + expect(new Set([first, otherPrincipal, otherOperator]).size).toBe(3); + }); + + it('keeps top-level brand and plans fallbacks compatible in authenticated mode', () => { + const topLevel = sessionKeyFromArgs( + { brand: { domain: 'House.Example' } }, + 'open', undefined, undefined, 'buyer-one', + ); + const plan = sessionKeyFromArgs( + { plans: [{ brand: { domain: 'house.example' } }] }, + 'open', undefined, undefined, 'buyer-one', + ); + expect(topLevel).toBe(plan); + expect(topLevel).toMatch(/:b:house\.example$/); + }); + + it('rejects malformed AccountRef before deriving an authenticated key', () => { + expect(() => sessionKeyFromArgs({ + account: { + account_id: 'acct_123', + brand: { domain: 'house.example' }, + operator: 'one.example', + }, + }, 'open', undefined, undefined, 'buyer-one')).toThrow(AccountRefValidationError); + }); +}); + +describe('custom-tool account scoping', () => { + it('reuses the shared canonical scope for top-level and usage accounts', () => { + const account = { + brand: { domain: 'House.Example', brand_id: 'spark' }, + operator: 'Pinnacle.Example', + sandbox: true, + }; + expect(deriveAccountScope({ account })) + .toBe('n:house.example:spark:pinnacle.example:1'); + expect(deriveAccountScope({ usage: [{ account }] })) + .toBe('n:house.example:spark:pinnacle.example:1'); + }); + + it('does not silently fall through an explicit invalid top-level account', () => { + expect(() => deriveAccountScope({ + account: null, + usage: [{ account: { account_id: 'acct_usage' } }], + })).toThrow(AccountRefValidationError); + }); + + it('returns INVALID_REQUEST before an idempotency-protected handler sees a mixed ref', async () => { + const handler = vi.fn(() => ({ ok: true })); + const tool = customToolFor('test_mutation', 'test', z.any(), handler, { + enforceIdempotency: true, + }); + const response = await (tool.handler as any)({ + idempotency_key: 'test-account-scope-key-0001', + account: { + account_id: 'acct_123', + brand: { domain: 'house.example' }, + operator: 'one.example', + }, + }, { authInfo: { clientId: 'buyer-one' } }); + + expect(response.isError).toBe(true); + expect(response.structuredContent.adcp_error).toMatchObject({ + code: 'INVALID_REQUEST', + field: 'account', + recovery: 'correctable', + }); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/training-agent-get-products-rejected.test.ts b/server/tests/unit/training-agent-get-products-rejected.test.ts index 51a4b05652..9be79aac6d 100644 --- a/server/tests/unit/training-agent-get-products-rejected.test.ts +++ b/server/tests/unit/training-agent-get-products-rejected.test.ts @@ -5,9 +5,6 @@ import type { TrainingContext } from '../../src/training-agent/types.js'; const account = (accountId: string) => ({ account_id: accountId, - brand: { domain: `${accountId}.example` }, - operator: 'test-operator', - sandbox: true, }); const call = async ( diff --git a/server/tests/unit/training-agent-idempotency.test.ts b/server/tests/unit/training-agent-idempotency.test.ts index 2e706216fc..fa8f7a87d8 100644 --- a/server/tests/unit/training-agent-idempotency.test.ts +++ b/server/tests/unit/training-agent-idempotency.test.ts @@ -19,7 +19,7 @@ import { invalidateCache, clearTaskStore, } from '../../src/training-agent/task-handlers.js'; -import { clearSessions, getSession } from '../../src/training-agent/state.js'; +import { clearSessions, getProductsSessionKeyFromArgs, getSession } from '../../src/training-agent/state.js'; import { MUTATING_TOOLS, REPLAY_TTL_SECONDS, @@ -451,7 +451,7 @@ describe('training agent idempotency middleware', () => { })); const failedIndex = firstResults.findIndex(({ result }) => result.isError === true); expect(failedIndex).toBeGreaterThanOrEqual(0); - expect((firstResults[failedIndex]!.result.structuredContent as any)?.adcp_error?.code).toBe('RATE_LIMITED'); + expect((firstResults[failedIndex]!.result.structuredContent as any)?.adcp_error?.code).toBe('CONFLICT'); const recovered = await callAsTask(server, 'get_products', payloads[failedIndex]!); const recoveredTaskId = (recovered.parsed.task as { taskId: string }).taskId; @@ -512,7 +512,7 @@ describe('training agent idempotency middleware', () => { const originalMediaBuyId = (recoveredResult.structuredContent as { media_buy_id?: string })?.media_buy_id; expect(originalMediaBuyId).toBeTruthy(); - const persistedSession = await getSession('open:idem-test.example'); + const persistedSession = await getSession(getProductsSessionKeyFromArgs({ account: ACCOUNT }, 'open')); expect(persistedSession.mediaBuys.size).toBe(1); expect([...persistedSession.mediaBuys.keys()]).toEqual([originalMediaBuyId]); expect(saveFailure).toHaveBeenCalledTimes(2); @@ -572,6 +572,86 @@ describe('training agent idempotency middleware', () => { expect((conflict.parsed as any).adcp_error?.code).toBe('IDEMPOTENCY_CONFLICT'); }); + it('preserves the exact committed proposal across later brief, wholesale, and refine discovery', async () => { + const account = { brand: { domain: 'idem-finalize-discovery.example' }, operator: 'idem-op' }; + const finalize = (idempotencyKey: string) => call(server, 'get_products', { + idempotency_key: idempotencyKey, + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', proposal_id: 'pinnacle_cross_channel' }], + }); + + const first = await finalize(`products-finalize-first-${randomUUID()}`); + const firstProposal = (first.parsed.proposals as Array>) + .find(proposal => proposal.proposal_id === 'pinnacle_cross_channel'); + expect(firstProposal).toMatchObject({ proposal_status: 'committed' }); + + await call(server, 'get_products', { + idempotency_key: `products-later-brief-${randomUUID()}`, + buying_mode: 'brief', + brief: 'podcast audio inventory', + account, + }); + const wholesale = await call(server, 'get_products', { + idempotency_key: `products-later-wholesale-${randomUUID()}`, + buying_mode: 'wholesale', + account, + }); + expect(wholesale.parsed.proposals).toBeUndefined(); + await call(server, 'get_products', { + idempotency_key: `products-later-refine-${randomUUID()}`, + buying_mode: 'refine', + account, + refine: [{ + scope: 'proposal', + action: 'include', + proposal_id: 'pinnacle_cross_channel', + ask: 'Use concrete fixed CPM pricing', + }], + }); + + const freshFinalize = await finalize(`products-finalize-fresh-${randomUUID()}`); + const freshProposal = (freshFinalize.parsed.proposals as Array>) + .find(proposal => proposal.proposal_id === 'pinnacle_cross_channel'); + expect(freshProposal).toEqual(firstProposal); + expect(freshProposal?.expires_at).toBe(firstProposal?.expires_at); + expect(freshProposal?.insertion_order).toEqual(firstProposal?.insertion_order); + }); + + it('rejects a malformed product cursor without changing committed proposal state', async () => { + const domain = 'idem-finalize-cursor.example'; + const account = { brand: { domain }, operator: 'idem-op' }; + const finalize = (idempotencyKey: string) => call(server, 'get_products', { + idempotency_key: idempotencyKey, + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', proposal_id: 'pinnacle_cross_channel' }], + }); + const first = await finalize(`products-cursor-finalize-${randomUUID()}`); + const firstProposal = (first.parsed.proposals as Array>) + .find(proposal => proposal.proposal_id === 'pinnacle_cross_channel'); + const sessionKey = getProductsSessionKeyFromArgs({ account }, 'open'); + const before = (await getSession(sessionKey)).lastGetProductsContext?.proposals; + + const malformed = await call(server, 'get_products', { + idempotency_key: `products-malformed-cursor-${randomUUID()}`, + buying_mode: 'wholesale', + account, + pagination: { cursor: 'not-a-products-cursor', max_results: 1 }, + }); + expect(malformed.isError).toBe(true); + expect((malformed.parsed as any).adcp_error).toMatchObject({ + code: 'INVALID_REQUEST', + field: 'pagination.cursor', + }); + expect((await getSession(sessionKey)).lastGetProductsContext?.proposals).toEqual(before); + + const freshFinalize = await finalize(`products-cursor-refinalize-${randomUUID()}`); + const freshProposal = (freshFinalize.parsed.proposals as Array>) + .find(proposal => proposal.proposal_id === 'pinnacle_cross_channel'); + expect(freshProposal).toEqual(firstProposal); + }); + it('serializes parallel proposal-finalize retries into one execution and one replay', async () => { const account = { brand: { domain: 'idem-concurrent-finalize.example' }, operator: 'idem-op' }; await call(server, 'get_products', { @@ -595,7 +675,10 @@ describe('training agent idempotency middleware', () => { expect(outcomes.filter((outcome) => outcome.isError !== true)).toHaveLength(1); const successful = outcomes.find((outcome) => outcome.isError !== true)!; const limited = outcomes.find((outcome) => outcome.isError === true)!; - expect((limited.parsed as any).adcp_error?.code).toBe('RATE_LIMITED'); + expect((limited.parsed as any).adcp_error).toMatchObject({ + code: 'IDEMPOTENCY_IN_FLIGHT', + retry_after: expect.any(Number), + }); const replay = await call(server, 'get_products', payload); expect(replay.parsed.replayed).toBe(true); @@ -628,7 +711,7 @@ describe('training agent idempotency middleware', () => { const limitedIndex = outcomes.findIndex(outcome => outcome.isError === true); expect(successful).toBeTruthy(); expect(limitedIndex).toBeGreaterThanOrEqual(0); - expect((outcomes[limitedIndex]!.parsed as any).adcp_error?.code).toBe('RATE_LIMITED'); + expect((outcomes[limitedIndex]!.parsed as any).adcp_error?.code).toBe('CONFLICT'); const retry = await call(server, 'get_products', payloads[limitedIndex]!); expect(retry.isError).toBeFalsy(); @@ -640,6 +723,39 @@ describe('training agent idempotency middleware', () => { expect(retryProposal?.expires_at).toBe(successfulProposal?.expires_at); }); + it('retains both proposals when concurrent disjoint finalizations retry after a session conflict', async () => { + const domain = 'idem-disjoint-finalize.example'; + const account = { brand: { domain }, operator: 'idem-op' }; + const proposalIds = ['pinnacle_cross_channel', 'viewpoint_multi_screen']; + const payloads = proposalIds.map((proposalId, index) => ({ + idempotency_key: `products-disjoint-finalize-${index}-${randomUUID()}`, + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', proposal_id: proposalId }], + })); + + const outcomes = await Promise.all(payloads.map(payload => call(server, 'get_products', payload))); + const successfulIndex = outcomes.findIndex(outcome => outcome.isError !== true); + const conflictedIndex = outcomes.findIndex(outcome => outcome.isError === true); + expect(successfulIndex).toBeGreaterThanOrEqual(0); + expect(conflictedIndex).toBeGreaterThanOrEqual(0); + expect((outcomes[conflictedIndex]!.parsed as any).adcp_error?.code).toBe('CONFLICT'); + + const successfulProposal = (outcomes[successfulIndex]!.parsed.proposals as Array>) + .find(proposal => proposal.proposal_id === proposalIds[successfulIndex]); + outcomes[conflictedIndex] = await call(server, 'get_products', payloads[conflictedIndex]!); + expect(outcomes[conflictedIndex]!.isError).toBeFalsy(); + + const persistedProposals = (await getSession(getProductsSessionKeyFromArgs({ account }, 'open'))) + .lastGetProductsContext?.proposals ?? []; + for (const proposalId of proposalIds) { + expect(persistedProposals.find(proposal => proposal.proposal_id === proposalId)) + .toMatchObject({ proposal_status: 'committed' }); + } + expect(persistedProposals.find(proposal => proposal.proposal_id === proposalIds[successfulIndex])) + .toEqual(successfulProposal); + }); + it('serializes overlapping proposal-finalize sets without double-committing either proposal', async () => { const account = { brand: { domain: 'idem-overlap-finalize.example' }, operator: 'idem-op' }; const singlePayload = { @@ -663,7 +779,7 @@ describe('training agent idempotency middleware', () => { const outcomes = await Promise.all(payloads.map(payload => call(server, 'get_products', payload))); const limitedIndex = outcomes.findIndex(outcome => outcome.isError === true); expect(limitedIndex).toBeGreaterThanOrEqual(0); - expect((outcomes[limitedIndex]!.parsed as any).adcp_error?.code).toBe('RATE_LIMITED'); + expect((outcomes[limitedIndex]!.parsed as any).adcp_error?.code).toBe('CONFLICT'); outcomes[limitedIndex] = await call(server, 'get_products', payloads[limitedIndex]!); expect(outcomes.every(outcome => outcome.isError !== true)).toBe(true); diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index ee3722284e..1d1891de1e 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -888,6 +888,39 @@ describe('session state', () => { }); }); + it('preserves disjoint mutations from overlapping requests', async () => { + const { InMemoryStateStore } = await import('@adcp/sdk/server'); + const store = new InMemoryStateStore(); + setStateStore(store); + const key = 'overlapping-disjoint-writes'; + try { + let loaded = 0; + let releaseBoth!: () => void; + const bothLoaded = new Promise(resolve => { releaseBoth = resolve; }); + const mutate = (field: 'mediaBuys' | 'creatives') => runWithSessionContext(async () => { + const session = await getSession(key); + if (field === 'mediaBuys') { + session.mediaBuys.set('mb1', { mediaBuyId: 'mb1', status: 'active' } as any); + } else { + session.creatives.set('creative1', { creativeId: 'creative1', status: 'approved' } as any); + } + loaded++; + if (loaded === 2) releaseBoth(); + await bothLoaded; + await flushDirtySessions(); + }); + await Promise.all([mutate('mediaBuys'), mutate('creatives')]); + + await runWithSessionContext(async () => { + const persisted = await getSession(key); + expect(persisted.mediaBuys.has('mb1')).toBe(true); + expect(persisted.creatives.has('creative1')).toBe(true); + }); + } finally { + setStateStore(null); + } + }); + it('returns different sessions for different keys', async () => { await runWithSessionContext(async () => { const s1 = await getSession('key-a'); @@ -1089,7 +1122,7 @@ describe('session state', () => { { account: { account_id: 'acc_acme_001' } }, 'open', ); - expect(key).toBe('open:acc_acme_001'); + expect(key).toBe('open:a:acc_acme_001'); }); it('uses top-level brand domain when account is absent', () => { diff --git a/static/compliance/source/protocols/media-buy/scenarios/get_products_async.yaml b/static/compliance/source/protocols/media-buy/scenarios/get_products_async.yaml index 687912213a..ccc55076fe 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/get_products_async.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/get_products_async.yaml @@ -243,7 +243,7 @@ phases: correlation_id: "get_products_async--get_products_submitted" validations: - check: error_code - allowed_values: ["IDEMPOTENCY_CONFLICT", "CONFLICT"] + allowed_values: ["IDEMPOTENCY_CONFLICT"] description: "Changed payload under the submitted request key is rejected" - id: list_products_task From b89def5280ebe4109eceb920325e1be707da8421 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 9 Aug 2026 14:45:55 +0200 Subject: [PATCH 06/49] fix(ci): preserve scoped compliance fixtures --- server/src/training-agent/account-handlers.ts | 26 ++++ .../training-agent/comply-test-controller.ts | 91 +++++++++++++- server/src/training-agent/state.ts | 109 +++++++++++++++-- server/src/training-agent/task-handlers.ts | 58 ++++++++- .../src/training-agent/v6-sales-platform.ts | 45 +++++-- .../tests/unit/comply-test-controller.test.ts | 37 ++++++ server/tests/unit/training-agent.test.ts | 115 ++++++++++++++++++ 7 files changed, 452 insertions(+), 29 deletions(-) diff --git a/server/src/training-agent/account-handlers.ts b/server/src/training-agent/account-handlers.ts index 0114b9f537..81b005eea6 100644 --- a/server/src/training-agent/account-handlers.ts +++ b/server/src/training-agent/account-handlers.ts @@ -160,6 +160,32 @@ function findAccountByIdAcrossSessions(accountId: string, principal?: string): A return undefined; } +/** Resolve a principal-owned sandbox account id to its trusted brand domain. */ +export function sandboxBrandDomainForAccountId( + accountId: string, + principal: string | undefined, +): string | undefined { + const account = findAccountByIdAcrossSessions(accountId, principal); + return account?.sandbox === true ? account.brand.domain.toLowerCase() : undefined; +} + +/** Resolve a principal-owned account id to its complete sandbox identity. */ +export function sandboxAccountRefForId( + accountId: string, + principal: string | undefined, +): AccountRef | undefined { + const account = findAccountByIdAcrossSessions(accountId, principal); + if (!account?.sandbox) return undefined; + return { + brand: { + domain: account.brand.domain.toLowerCase(), + ...(account.brand.brand_id && { brand_id: account.brand.brand_id }), + }, + operator: account.operator.toLowerCase(), + sandbox: true, + }; +} + function accountsForPrincipal(principal?: string): AccountState[] { const prefix = `${principalScope(principal)}\u001F`; const accounts: AccountState[] = []; diff --git a/server/src/training-agent/comply-test-controller.ts b/server/src/training-agent/comply-test-controller.ts index 6f7e388964..d36ed1a003 100644 --- a/server/src/training-agent/comply-test-controller.ts +++ b/server/src/training-agent/comply-test-controller.ts @@ -34,10 +34,21 @@ import type { ComplyBudgetSimulation, } from './types.js'; import { supportsGetProductsRejected } from './types.js'; -import { getProductsSessionKeyFromArgs, getSession, sessionKeyFromArgs } from './state.js'; +import { + findSessionMatching, + controllerFixturePrincipal, + getProductsSessionKeyFromArgs, + getSession, + sessionKeyFromArgs, +} from './state.js'; import { getAgentUrl } from './config.js'; import { randomUUID } from 'node:crypto'; -import { getAccountNotificationSubscribers, seedAccountFixture } from './account-handlers.js'; +import { + getAccountNotificationSubscribers, + sandboxAccountRefForId, + seedAccountFixture, +} from './account-handlers.js'; +import { canonicalizeAccountRef, type CanonicalAccountRef } from './account-scope.js'; import { verifyGovernanceToken, mintRevokedDemoToken, mintWrongAudDemoToken } from './governance-verify.js'; import { emitAccountNotificationWebhook } from './webhooks.js'; import { buildCatalog } from './product-factory.js'; @@ -52,6 +63,46 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +type NaturalAccountIdentity = Extract; + +function mediaBuySandboxIdentity( + mediaBuy: MediaBuyState, + principal: string | undefined, +): NaturalAccountIdentity | undefined { + const ref = mediaBuy.accountRef; + if (!ref) return undefined; + try { + const account = canonicalizeAccountRef(ref); + if (account.kind === 'account_id') { + const resolved = sandboxAccountRefForId(account.account_id, principal); + if (!resolved) return undefined; + const identity = canonicalizeAccountRef(resolved); + return identity.kind === 'natural' ? identity : undefined; + } + return account.sandbox ? account : undefined; + } catch { + return undefined; + } +} + +function sameBrandIdentity(a: NaturalAccountIdentity, b: NaturalAccountIdentity): boolean { + return a.brand.domain === b.brand.domain + && a.brand.brand_id === b.brand.brand_id; +} + +function controllerCanMutateMediaBuy( + controller: NaturalAccountIdentity, + mediaBuy: MediaBuyState, + principal: string | undefined, +): boolean { + const owner = mediaBuySandboxIdentity(mediaBuy, principal); + if (!owner || !sameBrandIdentity(controller, owner)) return false; + // Public/demo static credentials intentionally address one shared training + // sandbox. Real principals must match the complete canonical account, + // including operator and optional brand_id. + return principal?.startsWith('static:') === true || controller.operator === owner.operator; +} + // ── State machine transition tables ─────────────────────────────── const CREATIVE_TRANSITIONS: Record = { @@ -1127,10 +1178,42 @@ export async function handleComplyTestController(args: ToolArgs, ctx: TrainingCo const scenario = rawArgs.scenario; const targetsGetProductsState = scenario === 'force_get_products_arm' || (scenario === 'force_upstream_unavailable' && params.tool === 'get_products'); + const targetsControllerFixtureState = scenario === 'seed_product' + || scenario === 'seed_pricing_option' + || scenario === 'seed_measurement_catalog'; const sessionKey = targetsGetProductsState ? getProductsSessionKeyFromArgs(args, ctx.mode, ctx.userId, ctx.moduleId) - : sessionKeyFromArgs(args, ctx.mode, ctx.userId, ctx.moduleId); - const session = await getSession(sessionKey); + : sessionKeyFromArgs( + args, + ctx.mode, + ctx.userId, + ctx.moduleId, + targetsControllerFixtureState ? controllerFixturePrincipal(ctx.principal) : undefined, + ); + let session = await getSession(sessionKey); + if (scenario === 'simulate_delivery') { + const mediaBuyId = isRecord(rawArgs.params) && typeof rawArgs.params.media_buy_id === 'string' + ? rawArgs.params.media_buy_id + : undefined; + if (mediaBuyId && !session.mediaBuys.has(mediaBuyId)) { + let controllerAccount: NaturalAccountIdentity | undefined; + try { + const canonical = canonicalizeAccountRef(args.account); + if (canonical.kind === 'natural' && canonical.sandbox) { + controllerAccount = canonical; + } + } catch { + controllerAccount = undefined; + } + if (controllerAccount) { + session = await findSessionMatching(candidate => { + const mediaBuy = candidate.mediaBuys.get(mediaBuyId); + return mediaBuy !== undefined + && controllerCanMutateMediaBuy(controllerAccount, mediaBuy, ctx.principal); + }) ?? session; + } + } + } // Pre-dispatch local scenarios the SDK doesn't know about yet. The SDK's // dispatcher would return UNKNOWN_SCENARIO for these, so handle them before diff --git a/server/src/training-agent/state.ts b/server/src/training-agent/state.ts index cdba98f0bf..e1fce679ac 100644 --- a/server/src/training-agent/state.ts +++ b/server/src/training-agent/state.ts @@ -68,6 +68,26 @@ export { // ── Store factory ──────────────────────────────────────────────── let storeInstance: AdcpStateStore | null = null; +const knownSessionKeys = new Set(); +const MAX_KNOWN_IN_MEMORY_SESSION_KEYS = 10_000; +const projectedFixtureMaps = new WeakMap(); + +function trackInMemorySessionKey(key: string): void { + if (isDatabaseInitialized()) return; + // Refresh insertion order so the bounded index behaves as an LRU. The + // backing in-memory/custom store remains authoritative; this index only + // supports keyed cross-session scans where the SDK list API omits IDs. + knownSessionKeys.delete(key); + knownSessionKeys.add(key); + if (knownSessionKeys.size > MAX_KNOWN_IN_MEMORY_SESSION_KEYS) { + const oldest = knownSessionKeys.values().next().value as string | undefined; + if (oldest) knownSessionKeys.delete(oldest); + } +} function getStore(): AdcpStateStore { if (storeInstance) return storeInstance; @@ -83,6 +103,7 @@ export function setStateStore(store: AdcpStateStore | null): void { throw new Error('setStateStore is not allowed in production'); } storeInstance = store; + knownSessionKeys.clear(); } // ── Per-request cache via AsyncLocalStorage ────────────────────── @@ -407,8 +428,15 @@ export function getComplianceMediaBuy(id: string): MediaBuyState | undefined { * envelopes for Map/Date). Returns a JSON-safe Record. */ function serializeSession(session: SessionState): Record { + const localFixtures = projectedFixtureMaps.get(session); const persisted = { ...session, + ...(localFixtures && { + complyExtensions: { + ...session.complyExtensions, + ...localFixtures, + }, + }), // `products` is deterministic from the catalog — dropped from persistence // so callers re-derive on the next request. Only `proposals` (session- // specific drafts from refine workflows) ride along. @@ -490,7 +518,7 @@ function deserializeSession(data: Record): SessionState { * Between requests, a fresh read from the store happens, so different Fly * machines see each other's writes. */ -export async function getSession(key: string): Promise { +export async function getSession(key: string, controllerFixtureSessionKey?: string): Promise { const ctx = requestCtx.getStore(); if (ctx) { const cached = ctx.sessions.get(key); @@ -498,8 +526,24 @@ export async function getSession(key: string): Promise { } let storedShape: Record | null; + let sharedFixtureShape: Record | null = null; try { - storedShape = await getStore().get>(SESSIONS_COLLECTION, key); + const store = getStore(); + storedShape = await store.get>(SESSIONS_COLLECTION, key); + + // Callers may opt into one exact, principal-bound controller fixture + // session after validating the target is an authorized sandbox account. + // Only the explicitly tagged fixture maps are projected; no account state, + // proposals, media buys, or force directives cross the boundary. + if (controllerFixtureSessionKey && controllerFixtureSessionKey !== key) { + const cached = ctx?.sessions.get(controllerFixtureSessionKey); + sharedFixtureShape = cached + ? serializeSession(cached) + : await store.get>( + SESSIONS_COLLECTION, + controllerFixtureSessionKey, + ); + } } catch (err) { // Never turn an unavailable durable store into an apparent cache miss. // Creating and later flushing a fresh session here could overwrite the @@ -510,7 +554,26 @@ export async function getSession(key: string): Promise { // Only an authoritative missing-row result creates a fresh session. const session = storedShape ? deserializeSession(storedShape) : createSession(); + if (sharedFixtureShape) { + const shared = deserializeSession(sharedFixtureShape).complyExtensions; + const local = session.complyExtensions; + projectedFixtureMaps.set(session, { + seededProducts: local.seededProducts, + seededPricingOptions: local.seededPricingOptions, + seededMeasurementCatalogs: local.seededMeasurementCatalogs, + }); + local.seededProducts = new Map([...shared.seededProducts, ...local.seededProducts]); + local.seededPricingOptions = new Map([ + ...shared.seededPricingOptions, + ...local.seededPricingOptions, + ]); + local.seededMeasurementCatalogs = new Map([ + ...shared.seededMeasurementCatalogs, + ...local.seededMeasurementCatalogs, + ]); + } session.lastAccessedAt = new Date(); + trackInMemorySessionKey(key); if (ctx) { ctx.sessions.set(key, session); @@ -538,6 +601,11 @@ function principalDigest(principal: string): string { return createHash('sha256').update(principal).digest('hex'); } +/** Public/demo static keys all address the same non-production sandbox. */ +export function controllerFixturePrincipal(principal: string | undefined): string | undefined { + return principal?.startsWith('static:') ? 'static:sandbox-fixtures' : principal; +} + function principalScopedOpenKey(principal: string, scope: string): string { const prefix = `open:p:${principalDigest(principal)}:`; const candidate = `${prefix}${scope}`; @@ -744,16 +812,40 @@ export async function findSessionMatching(predicate: (s: SessionState) => boolea if (predicate(session)) return session; } } - const store = storeInstance; - if (!store) return null; try { - const page = await store.list>(SESSIONS_COLLECTION, { limit: 100 }); - for (const row of page.items ?? []) { - const session = deserializeSession(row); + if (isDatabaseInitialized()) { + // The generic SDK list API intentionally omits document IDs. Query the + // training store directly so legacy/pre-deploy rows remain attachable + // to RequestSessionCtx and mutations are durably flushed. Keyset paging + // avoids the old first-100-sessions blind spot. + let afterId = ''; + for (;;) { + const { rows } = await getPool().query<{ id: string; data: Record }>( + `SELECT id, data + FROM adcp_state + WHERE collection = $1 AND id > $2 + ORDER BY id + LIMIT 100`, + [SESSIONS_COLLECTION, afterId], + ); + for (const row of rows) { + if (predicate(deserializeSession(row.data))) return getSession(row.id); + } + if (rows.length < 100) break; + afterId = rows[rows.length - 1].id; + } + return null; + } + + // In-memory/custom stores are process-local in the training agent. Track + // every key opened through getSession so lookups keep the real key and are + // never performed on a detached deserialized copy. + for (const key of [...knownSessionKeys]) { + const session = await getSession(key); if (predicate(session)) return session; } } catch (err) { - logger.warn({ err }, 'findSessionMatching: store list failed'); + logger.warn({ err }, 'findSessionMatching: keyed session scan failed'); } return null; } @@ -774,6 +866,7 @@ export async function clearSessions(): Promise { ctx.sessions.clear(); ctx.snapshots.clear(); } + knownSessionKeys.clear(); const store = storeInstance; if (!store) return; if (store instanceof InMemoryStateStore) { diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 6577e327c0..e978b148e9 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -23,7 +23,11 @@ import { isDatabaseInitialized, getPool } from '../db/client.js'; import { createLogger } from '../logger.js'; import { isPrivateHostname, normalizeExternalHostname, safeFetchAxiosLike } from '../utils/url-security.js'; import { GET_PRODUCTS_REJECTED_ADCP_VERSION, supportsGetProductsRejected, type TrainingContext, type CatalogProduct, type MediaBuyState, type MediaBuyAvailableActionState, type MediaBuyProductAllowedActionState, type PackageState, type SignalActivationState, type CreativeState, type CreativeManifest, type ToolArgs, type ListReference, type PackageTargeting, type AccountRef, type SessionState } from './types.js'; -import { AccountRefValidationError, accountScopeFromRef } from './account-scope.js'; +import { + AccountRefValidationError, + accountScopeFromRef, + canonicalizeAccountRef, +} from './account-scope.js'; import { encodeOffsetCursor, decodeOffsetCursor } from './pagination.js'; import type { Product, @@ -1335,7 +1339,7 @@ import { buildCatalog, buildShowsForProducts, buildProposals } from './product-f import { buildFormats, FORMAT_CHANNEL_MAP } from './formats.js'; import { getAllSignals, SIGNAL_PROVIDERS } from './signal-providers.js'; import { - getSession, getProductsSessionKeyFromArgs, sessionKeyFromArgs, + controllerFixturePrincipal, getSession, getProductsSessionKeyFromArgs, sessionKeyFromArgs, findSessionMatching, runWithSessionContext, flushDirtySessions, getComplianceCreatives, getComplianceCreative, @@ -1380,6 +1384,7 @@ import { ACCOUNT_TOOLS, SUPPORTED_BILLINGS, handleListAccounts, + sandboxBrandDomainForAccountId, resolveAccountIdForRef, resolveGovernanceAgentsForAccount, handleSyncAccounts, @@ -1810,6 +1815,40 @@ function deriveAccountScope(args: Record, strictAccountRef = tr : undefined; } +/** + * Resolve the one principal-bound controller fixture session that an + * authenticated sandbox request may read. Account-id requests must prove the + * id was synced for the same principal and brand before a fixture key is + * returned; production/nonsandbox and malformed refs never bridge. + */ +function controllerFixtureSessionKey( + args: ToolArgs, + ctx: TrainingContext, +): string | undefined { + if (!args.account) return undefined; + let domain: string | undefined; + try { + const account = canonicalizeAccountRef(args.account); + if (account.kind === 'natural') { + if (!account.sandbox) return undefined; + domain = account.brand.domain; + } else { + domain = sandboxBrandDomainForAccountId(account.account_id, ctx.principal); + if (!domain) return undefined; + } + } catch { + return undefined; + } + + return sessionKeyFromArgs({ + account: { + brand: { domain }, + operator: domain, + sandbox: true, + }, + }, ctx.mode, ctx.userId, ctx.moduleId, controllerFixturePrincipal(ctx.principal)); +} + function withUsageAccountScope>(req: T): T { if (req.account !== undefined) return req; const usageAccount = Array.isArray(req.usage) @@ -3915,7 +3954,10 @@ async function handleGetProductsUnlocked( }; } } - const session = await getSession(getProductsSessionKeyFromArgs(req, ctx.mode, ctx.userId, ctx.moduleId)); + const session = await getSession( + getProductsSessionKeyFromArgs(req, ctx.mode, ctx.userId, ctx.moduleId), + controllerFixtureSessionKey(req, ctx), + ); const committedProposals = new Map( (session.lastGetProductsContext?.proposals ?? []) .filter(proposal => proposalLifecycle(proposal).proposal_status === 'committed') @@ -5395,7 +5437,10 @@ export async function handleValidateInput(args: ToolArgs, ctx: TrainingContext): const productTargets = targets.filter(target => target.kind === 'product'); const productsById = new Map(); if (productTargets.length > 0) { - const session = await getSession(sessionKeyFromArgs(req, ctx.mode, ctx.userId, ctx.moduleId)); + const session = await getSession( + sessionKeyFromArgs(req, ctx.mode, ctx.userId, ctx.moduleId), + controllerFixtureSessionKey(req as unknown as ToolArgs, ctx), + ); for (const catalogProduct of getCatalog()) { productsById.set(catalogProduct.product.product_id, { ...catalogProduct.product }); } @@ -5420,7 +5465,10 @@ export async function handleValidateInput(args: ToolArgs, ctx: TrainingContext): export async function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) { const req = args as unknown as CreateMediaBuyRequest & ToolArgs & { paused?: boolean }; - const session = await getSession(sessionKeyFromArgs(req, ctx.mode, ctx.userId, ctx.moduleId)); + const session = await getSession( + sessionKeyFromArgs(req, ctx.mode, ctx.userId, ctx.moduleId), + controllerFixtureSessionKey(req, ctx), + ); // Consume any single-shot directive registered by // comply_test_controller.force_create_media_buy_arm. Runs before all other diff --git a/server/src/training-agent/v6-sales-platform.ts b/server/src/training-agent/v6-sales-platform.ts index 0f7aaf9216..3200cf5acd 100644 --- a/server/src/training-agent/v6-sales-platform.ts +++ b/server/src/training-agent/v6-sales-platform.ts @@ -136,20 +136,26 @@ function brandDomainFromCtx(account: unknown): string | undefined { } function accountRefFromCtx(account: unknown): ToolArgs['account'] | undefined { - const acct = account as { id?: unknown; operator?: unknown; ctx_metadata?: TrainingSalesMeta } | undefined; + const acct = account as { + id?: unknown; + mode?: unknown; + operator?: unknown; + ctx_metadata?: TrainingSalesMeta; + } | undefined; const brandDomain = acct?.ctx_metadata?.brand_domain; const accountId = typeof acct?.id === 'string' && !acct.id.startsWith('synthetic_') && acct.id !== 'public_sandbox' ? acct.id : undefined; if (!accountId && !brandDomain) return undefined; + if (accountId) return { account_id: accountId }; return { - ...(accountId && { account_id: accountId }), ...(brandDomain && { brand: { domain: brandDomain } }), ...(typeof acct?.ctx_metadata?.operator === 'string' ? { operator: acct.ctx_metadata.operator } : typeof acct?.operator === 'string' ? { operator: acct.operator } : {}), + ...(acct?.mode === 'sandbox' && { sandbox: true }), }; } @@ -277,14 +283,19 @@ export class TrainingSalesPlatform agentRegistry = trainingBuyerAgentRegistry; // eslint-disable-next-line @typescript-eslint/no-explicit-any - sales: SalesPlatform = { + sales: SalesPlatform = { getProducts: async (req, ctx) => { // The installed SDK predates polymorphic get_products idempotency. Route // this one method through the shared schema-first dispatcher rather than // mutating the SDK's global task classification: validation, session // durability, and replay publication then share the same ordering as v5 // and direct Addie dispatch. - const versionResolution = resolveServedAdcpVersion(req as unknown as Record); + const accountRef = accountRefFromCtx(ctx.account); + const normalizedReq = { + ...(req as unknown as Record), + ...(accountRef && { account: accountRef }), + } as ToolArgs; + const versionResolution = resolveServedAdcpVersion(normalizedReq as unknown as Record); if (!versionResolution.ok) { throw new AdcpError('VERSION_UNSUPPORTED', { message: versionResolution.message, @@ -296,7 +307,7 @@ export class TrainingSalesPlatform trainingCtx.servedAdcpVersion = versionResolution.servedVersion; const executed = await executeTrainingAgentTool( 'get_products', - req as ToolArgs, + normalizedReq, trainingCtx, ); if (!executed.success) throwGetProductsExecutionError(executed.error ?? 'get_products failed'); @@ -304,7 +315,14 @@ export class TrainingSalesPlatform }, createMediaBuy: async (req, ctx) => { - const v5Result = await handleCreateMediaBuy(req as ToolArgs, buildTrainingCtx(ctx, this.storyboardCompat)); + const accountRef = accountRefFromCtx(ctx.account); + const brandDomain = brandDomainFromCtx(ctx.account); + const args = { + ...(req as unknown as Record), + ...(accountRef && { account: accountRef }), + ...(brandDomain && { brand: { domain: brandDomain } }), + } as ToolArgs; + const v5Result = await handleCreateMediaBuy(args, buildTrainingCtx(ctx, this.storyboardCompat)); // Detect the submitted-arm envelope the v5 handler returns when the // `force_create_media_buy_arm` test-controller directive is set. // The framework's projector rejects hand-rolled @@ -341,11 +359,12 @@ export class TrainingSalesPlatform updateMediaBuy: async (buyId, patch, ctx) => { const brandDomain = brandDomainFromCtx(ctx.account); + const accountRef = accountRefFromCtx(ctx.account); // brand placed after patch spread so it takes precedence over any brand // field the SDK might include in patch. const args = brandDomain - ? { media_buy_id: buyId, ...(patch as unknown as Record), brand: { domain: brandDomain } } - : { media_buy_id: buyId, ...(patch as unknown as Record) }; + ? { media_buy_id: buyId, ...(patch as unknown as Record), ...(accountRef && { account: accountRef }), brand: { domain: brandDomain } } + : { media_buy_id: buyId, ...(patch as unknown as Record), ...(accountRef && { account: accountRef }) }; const v5Result = await handleUpdateMediaBuy(args as ToolArgs, buildTrainingCtx(ctx, this.storyboardCompat)); return translateV5Result(v5Result); }, @@ -376,9 +395,10 @@ export class TrainingSalesPlatform getMediaBuyDelivery: async (filter, ctx) => { const brandDomain = brandDomainFromCtx(ctx.account); + const accountRef = accountRefFromCtx(ctx.account); const args = brandDomain - ? { ...(filter as unknown as Record), brand: { domain: brandDomain } } - : filter; + ? { ...(filter as unknown as Record), ...(accountRef && { account: accountRef }), brand: { domain: brandDomain } } + : { ...(filter as unknown as Record), ...(accountRef && { account: accountRef }) }; const result = await handleGetMediaBuyDelivery(args as ToolArgs, buildTrainingCtx(ctx, this.storyboardCompat)); return translateV5Result(result); }, @@ -386,9 +406,10 @@ export class TrainingSalesPlatform // Optional read-side methods. getMediaBuys: async (req, ctx) => { const brandDomain = brandDomainFromCtx(ctx.account); + const accountRef = accountRefFromCtx(ctx.account); const args = brandDomain - ? { ...(req as unknown as Record), brand: { domain: brandDomain } } - : req; + ? { ...(req as unknown as Record), ...(accountRef && { account: accountRef }), brand: { domain: brandDomain } } + : { ...(req as unknown as Record), ...(accountRef && { account: accountRef }) }; const result = await handleGetMediaBuys(args as ToolArgs, buildTrainingCtx(ctx, this.storyboardCompat)); return translateV5Result(result); }, diff --git a/server/tests/unit/comply-test-controller.test.ts b/server/tests/unit/comply-test-controller.test.ts index 016c71c3e8..f5c3dcc0fd 100644 --- a/server/tests/unit/comply-test-controller.test.ts +++ b/server/tests/unit/comply-test-controller.test.ts @@ -1724,6 +1724,43 @@ describe('comply_test_controller', () => { expect(result.error).toBe('NOT_FOUND'); }); + it('does not mutate a media buy through a mismatched sandbox account', async () => { + const mediaBuyId = await createMediaBuy(server); + const { result } = await simulateCallTool(server, 'comply_test_controller', { + scenario: 'simulate_delivery', + params: { media_buy_id: mediaBuyId, impressions: 100 }, + account: { + brand: { domain: 'other-brand.example' }, + operator: 'other-operator.example', + sandbox: true, + }, + brand: { domain: 'other-brand.example' }, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('NOT_FOUND'); + const owner = await getSession(sessionKeyFromArgs({ account: ACCOUNT }, 'open')); + expect(owner.complyExtensions.deliverySimulations.has(mediaBuyId)).toBe(false); + }); + + it('requires a full account match for non-static principals', async () => { + const scopedServer = createTrainingAgentServer({ mode: 'open', principal: 'workos:org-one' }); + const mediaBuyId = await createMediaBuy(scopedServer); + const { result } = await simulateCallTool(scopedServer, 'comply_test_controller', { + scenario: 'simulate_delivery', + params: { media_buy_id: mediaBuyId, impressions: 100 }, + account: { + brand: { domain: ACCOUNT.brand.domain }, + operator: 'different-operator.example', + sandbox: true, + }, + brand: BRAND, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('NOT_FOUND'); + }); + it('rejects delivery simulation for terminal media buy', async () => { const mediaBuyId = await createMediaBuyWithCreatives(server); diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index 1d1891de1e..dedc52f81c 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -11,12 +11,14 @@ import { stopSessionCleanup, runWithSessionContext, flushDirtySessions, + findMediaBuyAcrossSessions, MAX_MEDIA_BUYS_PER_SESSION, MAX_CREATIVES_PER_SESSION, SESSION_RETENTION_MS, SESSION_STORE_UNAVAILABLE_MESSAGE, sessionRetentionCutoff, setStateStore, + controllerFixturePrincipal, } from '../../src/training-agent/state.js'; import { createTrainingAgentServer, @@ -841,6 +843,13 @@ describe('session state', () => { }); describe('getSession', () => { + it('shares fixture identity only across explicit static sandbox principals', () => { + expect(controllerFixturePrincipal('static:public')).toBe('static:sandbox-fixtures'); + expect(controllerFixturePrincipal('static:demo:one')).toBe('static:sandbox-fixtures'); + expect(controllerFixturePrincipal('workos:org-one')).toBe('workos:org-one'); + expect(controllerFixturePrincipal('workos:org-two')).toBe('workos:org-two'); + }); + it('retains resources beyond the complete idempotency replay window', () => { const replayTtlMs = REPLAY_TTL_SECONDS * 1000; const cleanupAndSkewMarginMs = 60 * 60 * 1000; @@ -930,6 +939,112 @@ describe('session state', () => { }); }); + it('shares only controller fixture maps with account-scoped sandbox sessions', async () => { + const { InMemoryStateStore } = await import('@adcp/sdk/server'); + const store = new InMemoryStateStore(); + setStateStore(store); + try { + const fixtureKey = sessionKeyFromArgs({ + account: { + brand: { domain: 'brand.example' }, + operator: 'brand.example', + sandbox: true, + }, + }, 'open', undefined, undefined, 'principal-one'); + await runWithSessionContext(async () => { + const controller = await getSession(fixtureKey); + controller.complyExtensions.seededProducts.set('seeded-product', { + product_id: 'seeded-product', + }); + controller.complyExtensions.seededPricingOptions.set('seeded-product:cpm', { + product_id: 'seeded-product', + pricing_option_id: 'cpm', + }); + controller.mediaBuys.set('private-buy', { mediaBuyId: 'private-buy' } as any); + controller.complyExtensions.forcedUpstreamUnavailable = { + tool: 'get_products', + createdAt: new Date().toISOString(), + }; + await flushDirtySessions(); + }); + + await runWithSessionContext(async () => { + const account = await getSession('open:a:account-one', fixtureKey); + expect(account.complyExtensions.seededProducts.has('seeded-product')).toBe(true); + expect(account.complyExtensions.seededPricingOptions.has('seeded-product:cpm')).toBe(true); + expect(account.mediaBuys.has('private-buy')).toBe(false); + expect(account.complyExtensions.forcedUpstreamUnavailable).toBeUndefined(); + await flushDirtySessions(); + }); + + // Inherited fixtures are a read-through view and do not create an + // account row until that account makes a real mutation. + expect(await store.get('training_sessions', 'open:a:account-one')).toBeNull(); + + await runWithSessionContext(async () => { + const account = await getSession('open:a:account-one', fixtureKey); + account.mediaBuys.set('account-buy', { mediaBuyId: 'account-buy' } as any); + await flushDirtySessions(); + }); + await runWithSessionContext(async () => { + const reloaded = await getSession('open:a:account-one'); + expect(reloaded.mediaBuys.has('account-buy')).toBe(true); + expect(reloaded.complyExtensions.seededProducts.size).toBe(0); + expect(reloaded.complyExtensions.seededPricingOptions.size).toBe(0); + }); + } finally { + setStateStore(null); + } + }); + + it('persists mutations made through a cross-session media-buy lookup', async () => { + const { InMemoryStateStore } = await import('@adcp/sdk/server'); + const store = new InMemoryStateStore(); + setStateStore(store); + try { + await runWithSessionContext(async () => { + const owner = await getSession('open:a:media-buy-owner'); + owner.mediaBuys.set('indexed-buy', { + mediaBuyId: 'indexed-buy', + status: 'active', + } as any); + await flushDirtySessions(); + }); + + await runWithSessionContext(async () => { + const found = await findMediaBuyAcrossSessions('indexed-buy'); + expect(found).not.toBeNull(); + found!.mediaBuys.get('indexed-buy')!.status = 'paused'; + await flushDirtySessions(); + }); + + await runWithSessionContext(async () => { + const owner = await getSession('open:a:media-buy-owner'); + expect(owner.mediaBuys.get('indexed-buy')?.status).toBe('paused'); + }); + } finally { + setStateStore(null); + } + }); + + it('terminates an in-memory cross-session lookup miss', async () => { + const { InMemoryStateStore } = await import('@adcp/sdk/server'); + const store = new InMemoryStateStore(); + setStateStore(store); + try { + await runWithSessionContext(async () => { + const session = await getSession('open:a:known-session'); + session.mediaBuys.set('known-buy', { mediaBuyId: 'known-buy' } as any); + await flushDirtySessions(); + }); + await runWithSessionContext(async () => { + await expect(findMediaBuyAcrossSessions('missing-buy')).resolves.toBeNull(); + }); + } finally { + setStateStore(null); + } + }); + it('updates lastAccessedAt on every access', async () => { await runWithSessionContext(async () => { const s1 = await getSession('test-key'); From e1ae78a71064390bb4ecb5e3c82e42825463f74f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 9 Aug 2026 15:03:20 +0200 Subject: [PATCH 07/49] fix(ci): preserve 3.0 session routing --- .../training-agent/comply-test-controller.ts | 17 +- server/src/training-agent/task-handlers.ts | 20 ++- server/src/training-agent/tenants/comply.ts | 75 +++++---- .../tenants/creative-builder.ts | 2 +- server/src/training-agent/tenants/creative.ts | 2 +- server/src/training-agent/tenants/sales.ts | 2 +- .../training-agent/v6-creative-platform.ts | 21 ++- .../src/training-agent/v6-sales-platform.ts | 111 +++++++------ .../training-agent-3-0-compat-tools.test.ts | 153 ++++++++++++++++++ 9 files changed, 318 insertions(+), 85 deletions(-) diff --git a/server/src/training-agent/comply-test-controller.ts b/server/src/training-agent/comply-test-controller.ts index d36ed1a003..3475fca350 100644 --- a/server/src/training-agent/comply-test-controller.ts +++ b/server/src/training-agent/comply-test-controller.ts @@ -1181,10 +1181,23 @@ export async function handleComplyTestController(args: ToolArgs, ctx: TrainingCo const targetsControllerFixtureState = scenario === 'seed_product' || scenario === 'seed_pricing_option' || scenario === 'seed_measurement_catalog'; + // The frozen 3.0 runner injects a synthetic natural account into controller + // and fixture calls, sometimes without copying its brand to the top level. + // Platform methods on that compatibility surface historically key by brand. + // Preserve opaque account IDs, but project natural refs back to their brand + // so directives and fixtures reach the same legacy session. + const legacyNaturalBrandDomain = ctx.storyboardCompat?.version === '3.0' + && args.account + && !args.account.account_id + ? args.brand?.domain ?? args.account.brand?.domain + : undefined; + const sessionArgs = legacyNaturalBrandDomain + ? { ...args, account: undefined, brand: { domain: legacyNaturalBrandDomain } } + : args; const sessionKey = targetsGetProductsState - ? getProductsSessionKeyFromArgs(args, ctx.mode, ctx.userId, ctx.moduleId) + ? getProductsSessionKeyFromArgs(sessionArgs, ctx.mode, ctx.userId, ctx.moduleId) : sessionKeyFromArgs( - args, + sessionArgs, ctx.mode, ctx.userId, ctx.moduleId, diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index e978b148e9..3e778b47c2 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -1825,7 +1825,25 @@ function controllerFixtureSessionKey( args: ToolArgs, ctx: TrainingContext, ): string | undefined { - if (!args.account) return undefined; + if (!args.account) { + // Frozen 3.0 natural-account adapters intentionally project the SDK's + // synthetic account back to its legacy brand session. Permit that one + // static sandbox surface to read the principal-bound fixture projection; + // real principals and ordinary brand-only requests remain ineligible. + const compatBrandDomain = ctx.storyboardCompat?.version === '3.0' + && ctx.principal?.startsWith('static:') + && typeof args.brand?.domain === 'string' + ? args.brand.domain + : undefined; + if (!compatBrandDomain) return undefined; + return sessionKeyFromArgs( + { brand: { domain: compatBrandDomain } }, + ctx.mode, + ctx.userId, + ctx.moduleId, + controllerFixturePrincipal(ctx.principal), + ); + } let domain: string | undefined; try { const account = canonicalizeAccountRef(args.account); diff --git a/server/src/training-agent/tenants/comply.ts b/server/src/training-agent/tenants/comply.ts index 9540f85f09..3e63ea6580 100644 --- a/server/src/training-agent/tenants/comply.ts +++ b/server/src/training-agent/tenants/comply.ts @@ -64,7 +64,12 @@ function normalizeControllerErrorCode(code: unknown): ControllerErrorCode { * Generic v5 → v6 comply-adapter shim. Builds the `ToolArgs` for the v5 * handler, dispatches, throws `TestControllerError` on `success: false`. */ -async function dispatchV5(scenario: string, params: Record, input: Record): Promise { +async function dispatchV5( + scenario: string, + params: Record, + input: Record, + storyboardCompat?: TrainingContext['storyboardCompat'], +): Promise { // v5 handler reads brand/account from the wire-shaped args to derive // the session key. `ctx.input` is the full raw input (including // brand/account/sandbox/etc.), so spread it and stamp scenario+params. @@ -74,7 +79,11 @@ async function dispatchV5(scenario: string, params: Record, inp const cleanInput = { ...input }; delete cleanInput[TRAINING_PRINCIPAL_FIELD]; const args = { ...cleanInput, scenario, params } as ToolArgs; - return await handleComplyTestController(args, { mode: 'open', principal } satisfies TrainingContext) as V5Response; + return await handleComplyTestController(args, { + mode: 'open', + principal, + ...(storyboardCompat && { storyboardCompat }), + } satisfies TrainingContext) as V5Response; } function throwOnFailure(result: V5Response): void { @@ -94,26 +103,26 @@ function throwOnFailure(result: V5Response): void { // site narrow back to the typed adapter shape. type AdapterShim = (params: unknown, ctx: ComplyControllerContext) => Promise; -function seedAdapter(scenario: string): AdapterShim { +function seedAdapter(scenario: string, storyboardCompat?: TrainingContext['storyboardCompat']): AdapterShim { return async (params, ctx) => { - const result = await dispatchV5(scenario, params as Record, ctx.input); + const result = await dispatchV5(scenario, params as Record, ctx.input, storyboardCompat); throwOnFailure(result); // Seed adapters return void — framework builds SeedSuccess envelope // from its own idempotency cache. }; } -function forceAdapter(scenario: string): AdapterShim { +function forceAdapter(scenario: string, storyboardCompat?: TrainingContext['storyboardCompat']): AdapterShim { return async (params, ctx) => { - const result = await dispatchV5(scenario, params as Record, ctx.input); + const result = await dispatchV5(scenario, params as Record, ctx.input, storyboardCompat); throwOnFailure(result); return result; }; } -function simulateAdapter(scenario: string): AdapterShim { +function simulateAdapter(scenario: string, storyboardCompat?: TrainingContext['storyboardCompat']): AdapterShim { return async (params, ctx) => { - const result = await dispatchV5(scenario, params as Record, ctx.input); + const result = await dispatchV5(scenario, params as Record, ctx.input, storyboardCompat); throwOnFailure(result); return result; }; @@ -184,63 +193,67 @@ export function buildGovernanceComplyConfig(): ComplyControllerConfig { * ad-server, plus sales seeds for storyboards that set up a sales * context before exercising creative flows (creative_generative/seller). */ -export function buildCreativeComplyConfig(): ComplyControllerConfig { +export function buildCreativeComplyConfig( + storyboardCompat?: TrainingContext['storyboardCompat'], +): ComplyControllerConfig { // eslint-disable-next-line @typescript-eslint/no-explicit-any const cast = (a: AdapterShim) => a as any; return { inputSchema: SALES_COMPLY_INPUT_SCHEMA, seed: { - creative: cast(seedAdapter('seed_creative')), + creative: cast(seedAdapter('seed_creative', storyboardCompat)), // F14 (`bd0d4028`) added the `creative_format` slot — needed for // `pagination_integrity_creative_formats` storyboard which seeds // multiple format fixtures and walks list_creative_formats pagination. - creative_format: cast(seedAdapter('seed_creative_format')), - product: cast(seedAdapter('seed_product')), - pricing_option: cast(seedAdapter('seed_pricing_option')), - media_buy: cast(seedAdapter('seed_media_buy')), + creative_format: cast(seedAdapter('seed_creative_format', storyboardCompat)), + product: cast(seedAdapter('seed_product', storyboardCompat)), + pricing_option: cast(seedAdapter('seed_pricing_option', storyboardCompat)), + media_buy: cast(seedAdapter('seed_media_buy', storyboardCompat)), }, force: { - creative_status: cast(forceAdapter('force_creative_status')), - media_buy_status: cast(forceAdapter('force_media_buy_status')), + creative_status: cast(forceAdapter('force_creative_status', storyboardCompat)), + media_buy_status: cast(forceAdapter('force_media_buy_status', storyboardCompat)), }, }; } -export function buildSalesComplyConfig(): ComplyControllerConfig { +export function buildSalesComplyConfig( + storyboardCompat?: TrainingContext['storyboardCompat'], +): ComplyControllerConfig { // eslint-disable-next-line @typescript-eslint/no-explicit-any const cast = (a: AdapterShim) => a as any; return { inputSchema: SALES_COMPLY_INPUT_SCHEMA, seed: { - product: cast(seedAdapter('seed_product')), - pricing_option: cast(seedAdapter('seed_pricing_option')), - media_buy: cast(seedAdapter('seed_media_buy')), - creative: cast(seedAdapter('seed_creative')), + product: cast(seedAdapter('seed_product', storyboardCompat)), + pricing_option: cast(seedAdapter('seed_pricing_option', storyboardCompat)), + media_buy: cast(seedAdapter('seed_media_buy', storyboardCompat)), + creative: cast(seedAdapter('seed_creative', storyboardCompat)), // /sales advertises list_creative_formats (the SDK auto-registers it for // any tenant claiming a creative archetype) so the universal // pagination_integrity_creative_formats storyboard fires here too. The // seed adapter routes through the v5 handler's LOCAL_SCENARIOS path, // populating the process-global seeded format pool that // list_creative_formats reads. - creative_format: cast(seedAdapter('seed_creative_format')), + creative_format: cast(seedAdapter('seed_creative_format', storyboardCompat)), }, force: { - media_buy_status: cast(forceAdapter('force_media_buy_status')), - create_media_buy_arm: cast(forceAdapter('force_create_media_buy_arm')), - get_products_arm: cast(forceAdapter('force_get_products_arm')), - task_completion: cast(forceAdapter('force_task_completion')), + media_buy_status: cast(forceAdapter('force_media_buy_status', storyboardCompat)), + create_media_buy_arm: cast(forceAdapter('force_create_media_buy_arm', storyboardCompat)), + get_products_arm: cast(forceAdapter('force_get_products_arm', storyboardCompat)), + task_completion: cast(forceAdapter('force_task_completion', storyboardCompat)), // force_creative_status drives dependency_impairment storyboards — // toggles creative.status and propagates to dependent media buys' // impairments[] via the v5 store's propagateCreativeImpairment. - creative_status: cast(forceAdapter('force_creative_status')), + creative_status: cast(forceAdapter('force_creative_status', storyboardCompat)), // Audience sibling: suspends a synced audience and propagates the // resulting impairment to packages that target it. - audience_status: cast(forceAdapter('force_audience_status')), - upstream_unavailable: cast(forceAdapter('force_upstream_unavailable')), + audience_status: cast(forceAdapter('force_audience_status', storyboardCompat)), + upstream_unavailable: cast(forceAdapter('force_upstream_unavailable', storyboardCompat)), }, simulate: { - delivery: cast(simulateAdapter('simulate_delivery')), - budget_spend: cast(simulateAdapter('simulate_budget_spend')), + delivery: cast(simulateAdapter('simulate_delivery', storyboardCompat)), + budget_spend: cast(simulateAdapter('simulate_budget_spend', storyboardCompat)), }, }; } diff --git a/server/src/training-agent/tenants/creative-builder.ts b/server/src/training-agent/tenants/creative-builder.ts index 6098fe8ad0..4265abd516 100644 --- a/server/src/training-agent/tenants/creative-builder.ts +++ b/server/src/training-agent/tenants/creative-builder.ts @@ -41,7 +41,7 @@ export function buildCreativeBuilderTenantConfig(host: string, options: { storyb list_transformers: listTransformersTool(), }), }, - complyTest: buildCreativeComplyConfig(), + complyTest: buildCreativeComplyConfig(options.storyboardCompat), }, }, }; diff --git a/server/src/training-agent/tenants/creative.ts b/server/src/training-agent/tenants/creative.ts index 1530db08ae..1354469eb5 100644 --- a/server/src/training-agent/tenants/creative.ts +++ b/server/src/training-agent/tenants/creative.ts @@ -40,7 +40,7 @@ export function buildCreativeTenantConfig(host: string, options: { storyboardCom list_transformers: listTransformersTool(), }), }, - complyTest: buildCreativeComplyConfig(), + complyTest: buildCreativeComplyConfig(options.storyboardCompat), }, }, }; diff --git a/server/src/training-agent/tenants/sales.ts b/server/src/training-agent/tenants/sales.ts index ffd5d439df..973e832819 100644 --- a/server/src/training-agent/tenants/sales.ts +++ b/server/src/training-agent/tenants/sales.ts @@ -120,7 +120,7 @@ export function buildSalesTenantConfig(host: string, options: { storyboardCompat }), }), }, - complyTest: buildSalesComplyConfig(), + complyTest: buildSalesComplyConfig(options.storyboardCompat), }, }, }; diff --git a/server/src/training-agent/v6-creative-platform.ts b/server/src/training-agent/v6-creative-platform.ts index 03c407bdd1..d9e176b7db 100644 --- a/server/src/training-agent/v6-creative-platform.ts +++ b/server/src/training-agent/v6-creative-platform.ts @@ -140,7 +140,26 @@ export class TrainingCreativePlatform creative: CreativeAdServerPlatform = { buildCreative: async (req, ctx) => { - const result = await handleBuildCreative(req as ToolArgs, buildTrainingCtx(ctx, this.storyboardCompat)); + // Frozen 3.0 build_creative schemas omit account, while sync_creatives + // still resolves the caller account through the v6 context. Restore the + // resolved brand fallback so both calls address the same legacy session. + const brandDomain = (ctx.account as { ctx_metadata?: { brand_domain?: string } } | undefined) + ?.ctx_metadata?.brand_domain; + const resolvedAccountId = (ctx.account as { id?: unknown } | undefined)?.id; + const accountId = typeof resolvedAccountId === 'string' + && !resolvedAccountId.startsWith('synthetic_') + && resolvedAccountId !== 'public_sandbox' + ? resolvedAccountId + : undefined; + const input = req as unknown as Record; + const { account: _legacyAccount, ...withoutAccount } = input; + const base = this.storyboardCompat?.version === '3.0' ? withoutAccount : input; + const args = { + ...base, + ...(this.storyboardCompat?.version === '3.0' && accountId && { account: { account_id: accountId } }), + ...(brandDomain && { brand: { domain: brandDomain } }), + }; + const result = await handleBuildCreative(args as ToolArgs, buildTrainingCtx(ctx, this.storyboardCompat)); // F16 (`bca20dfb`) — framework's discriminator passes through // pre-shaped BuildCreativeSuccess / BuildCreativeMultiSuccess // envelopes. v5 returns the envelope shape directly. diff --git a/server/src/training-agent/v6-sales-platform.ts b/server/src/training-agent/v6-sales-platform.ts index 3200cf5acd..d9a9573dff 100644 --- a/server/src/training-agent/v6-sales-platform.ts +++ b/server/src/training-agent/v6-sales-platform.ts @@ -135,7 +135,14 @@ function brandDomainFromCtx(account: unknown): string | undefined { return (account as { ctx_metadata?: TrainingSalesMeta } | undefined)?.ctx_metadata?.brand_domain; } -function accountRefFromCtx(account: unknown): ToolArgs['account'] | undefined { +function accountRefFromCtx( + account: unknown, + storyboardCompat?: TrainingContext['storyboardCompat'], +): ToolArgs['account'] | undefined { + // The v6 framework removes the envelope account before invoking platform + // methods. Frozen 3.0 storyboards and their controller steps historically + // shared the remaining top-level brand session; re-injecting the resolved + // account only on platform methods splits that compatibility-only flow. const acct = account as { id?: unknown; mode?: unknown; @@ -146,8 +153,9 @@ function accountRefFromCtx(account: unknown): ToolArgs['account'] | undefined { const accountId = typeof acct?.id === 'string' && !acct.id.startsWith('synthetic_') && acct.id !== 'public_sandbox' ? acct.id : undefined; - if (!accountId && !brandDomain) return undefined; if (accountId) return { account_id: accountId }; + if (storyboardCompat?.version === '3.0') return undefined; + if (!accountId && !brandDomain) return undefined; return { ...(brandDomain && { brand: { domain: brandDomain } }), ...(typeof acct?.ctx_metadata?.operator === 'string' @@ -159,6 +167,22 @@ function accountRefFromCtx(account: unknown): ToolArgs['account'] | undefined { }; } +function withResolvedAccountScope( + input: Record, + account: unknown, + storyboardCompat?: TrainingContext['storyboardCompat'], +): ToolArgs { + const { account: _legacyAccount, ...withoutAccount } = input; + const base = storyboardCompat?.version === '3.0' ? withoutAccount : input; + const accountRef = accountRefFromCtx(account, storyboardCompat); + const brandDomain = brandDomainFromCtx(account); + return { + ...base, + ...(accountRef && { account: accountRef }), + ...(brandDomain && { brand: { domain: brandDomain } }), + } as ToolArgs; +} + /** * v5 → v6 envelope translator. v5 handlers return `{ errors: [...] }` for * structured rejection; v6 platform methods throw `AdcpError`. @@ -283,18 +307,18 @@ export class TrainingSalesPlatform agentRegistry = trainingBuyerAgentRegistry; // eslint-disable-next-line @typescript-eslint/no-explicit-any - sales: SalesPlatform = { + sales: SalesPlatform = { getProducts: async (req, ctx) => { // The installed SDK predates polymorphic get_products idempotency. Route // this one method through the shared schema-first dispatcher rather than // mutating the SDK's global task classification: validation, session // durability, and replay publication then share the same ordering as v5 // and direct Addie dispatch. - const accountRef = accountRefFromCtx(ctx.account); - const normalizedReq = { - ...(req as unknown as Record), - ...(accountRef && { account: accountRef }), - } as ToolArgs; + const normalizedReq = withResolvedAccountScope( + req as unknown as Record, + ctx.account, + this.storyboardCompat, + ); const versionResolution = resolveServedAdcpVersion(normalizedReq as unknown as Record); if (!versionResolution.ok) { throw new AdcpError('VERSION_UNSUPPORTED', { @@ -315,13 +339,11 @@ export class TrainingSalesPlatform }, createMediaBuy: async (req, ctx) => { - const accountRef = accountRefFromCtx(ctx.account); - const brandDomain = brandDomainFromCtx(ctx.account); - const args = { - ...(req as unknown as Record), - ...(accountRef && { account: accountRef }), - ...(brandDomain && { brand: { domain: brandDomain } }), - } as ToolArgs; + const args = withResolvedAccountScope( + req as unknown as Record, + ctx.account, + this.storyboardCompat, + ); const v5Result = await handleCreateMediaBuy(args, buildTrainingCtx(ctx, this.storyboardCompat)); // Detect the submitted-arm envelope the v5 handler returns when the // `force_create_media_buy_arm` test-controller directive is set. @@ -358,19 +380,16 @@ export class TrainingSalesPlatform }, updateMediaBuy: async (buyId, patch, ctx) => { - const brandDomain = brandDomainFromCtx(ctx.account); - const accountRef = accountRefFromCtx(ctx.account); - // brand placed after patch spread so it takes precedence over any brand - // field the SDK might include in patch. - const args = brandDomain - ? { media_buy_id: buyId, ...(patch as unknown as Record), ...(accountRef && { account: accountRef }), brand: { domain: brandDomain } } - : { media_buy_id: buyId, ...(patch as unknown as Record), ...(accountRef && { account: accountRef }) }; - const v5Result = await handleUpdateMediaBuy(args as ToolArgs, buildTrainingCtx(ctx, this.storyboardCompat)); + const args = withResolvedAccountScope( + { media_buy_id: buyId, ...(patch as unknown as Record) }, + ctx.account, + this.storyboardCompat, + ); + const v5Result = await handleUpdateMediaBuy(args, buildTrainingCtx(ctx, this.storyboardCompat)); return translateV5Result(v5Result); }, syncCreatives: async (creatives, ctx) => { - const brandDomain = brandDomainFromCtx(ctx.account); // `dry_run` and `assignments[]` are dropped from the v6 typed // signature (adcp-client#1842). Lift them back off `ctx.input` so // the v5 handler honors dry-run mode and writes inline @@ -379,14 +398,11 @@ export class TrainingSalesPlatform // `assignments[]` are observable via subsequent `get_media_buys`, // not in the sync_creatives response itself. const fromInput = pickFromInput(ctx.input, ['assignments', 'dry_run', 'account'] as const); - const accountRef = (fromInput as { account?: ToolArgs['account'] }).account ?? accountRefFromCtx(ctx.account); - const args = { + const args = withResolvedAccountScope({ creatives, ...fromInput, - ...(accountRef && { account: accountRef }), - ...(brandDomain && { brand: { domain: brandDomain } }), - }; - const v5Result = await handleSyncCreatives(args as unknown as ToolArgs, buildTrainingCtx(ctx, this.storyboardCompat)); + }, ctx.account, this.storyboardCompat); + const v5Result = await handleSyncCreatives(args, buildTrainingCtx(ctx, this.storyboardCompat)); // v5 returns wire-wrapped `{ creatives: [...] }`; v6 SalesPlatform // wants rows directly — framework re-wraps. const wrapped = translateV5Result<{ creatives?: unknown[] }>(v5Result); @@ -394,23 +410,23 @@ export class TrainingSalesPlatform }, getMediaBuyDelivery: async (filter, ctx) => { - const brandDomain = brandDomainFromCtx(ctx.account); - const accountRef = accountRefFromCtx(ctx.account); - const args = brandDomain - ? { ...(filter as unknown as Record), ...(accountRef && { account: accountRef }), brand: { domain: brandDomain } } - : { ...(filter as unknown as Record), ...(accountRef && { account: accountRef }) }; - const result = await handleGetMediaBuyDelivery(args as ToolArgs, buildTrainingCtx(ctx, this.storyboardCompat)); + const args = withResolvedAccountScope( + filter as unknown as Record, + ctx.account, + this.storyboardCompat, + ); + const result = await handleGetMediaBuyDelivery(args, buildTrainingCtx(ctx, this.storyboardCompat)); return translateV5Result(result); }, // Optional read-side methods. getMediaBuys: async (req, ctx) => { - const brandDomain = brandDomainFromCtx(ctx.account); - const accountRef = accountRefFromCtx(ctx.account); - const args = brandDomain - ? { ...(req as unknown as Record), ...(accountRef && { account: accountRef }), brand: { domain: brandDomain } } - : { ...(req as unknown as Record), ...(accountRef && { account: accountRef }) }; - const result = await handleGetMediaBuys(args as ToolArgs, buildTrainingCtx(ctx, this.storyboardCompat)); + const args = withResolvedAccountScope( + req as unknown as Record, + ctx.account, + this.storyboardCompat, + ); + const result = await handleGetMediaBuys(args, buildTrainingCtx(ctx, this.storyboardCompat)); return translateV5Result(result); }, @@ -420,11 +436,12 @@ export class TrainingSalesPlatform }, listCreatives: async (req, ctx) => { - const brandDomain = brandDomainFromCtx(ctx.account); - const args = brandDomain - ? { ...(req as unknown as Record), brand: { domain: brandDomain } } - : req; - const result = await handleListCreatives(args as ToolArgs, buildTrainingCtx(ctx, this.storyboardCompat)); + const args = withResolvedAccountScope( + req as unknown as Record, + ctx.account, + this.storyboardCompat, + ); + const result = await handleListCreatives(args, buildTrainingCtx(ctx, this.storyboardCompat)); return translateV5Result(result); }, diff --git a/server/tests/integration/training-agent-3-0-compat-tools.test.ts b/server/tests/integration/training-agent-3-0-compat-tools.test.ts index ffaa976d94..b3830dcdbe 100644 --- a/server/tests/integration/training-agent-3-0-compat-tools.test.ts +++ b/server/tests/integration/training-agent-3-0-compat-tools.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, afterAll, vi } from 'vitest'; import express from 'express'; import http from 'node:http'; import { AddressInfo } from 'node:net'; +import { randomUUID } from 'node:crypto'; vi.hoisted(() => { process.env.PUBLIC_TEST_AGENT_TOKEN = 'compat-tools-token'; @@ -205,4 +206,156 @@ describe('training-agent 3.0 compat tool visibility', () => { await close(); } }); + + it('keeps frozen 3.0 controller directives in the media buy brand session', async () => { + const { baseUrl, close } = await bootCompatRouter(); + const domain = `compat-controller-${randomUUID()}.example`; + const account = { brand: { domain }, operator: 'pinnacle-agency.example' }; + try { + const created = await callTenantTool(baseUrl, 'sales', 'create_media_buy', { + account, + brand: { domain }, + start_time: '2099-04-01T00:00:00Z', + end_time: '2099-06-30T23:59:59Z', + packages: [{ product_id: 'test-product', pricing_option_id: 'test-pricing', budget: 5000 }], + idempotency_key: randomUUID(), + }); + expect(created.adcp_error, JSON.stringify(created)).toBeUndefined(); + expect(created.media_buy_id).toEqual(expect.any(String)); + + const completed = await callTenantTool(baseUrl, 'sales', 'comply_test_controller', { + brand: { domain }, + scenario: 'force_media_buy_status', + params: { media_buy_id: created.media_buy_id, status: 'completed' }, + }); + expect(completed).toMatchObject({ success: true, current_state: 'completed' }); + + const rejected = await callTenantTool(baseUrl, 'sales', 'comply_test_controller', { + brand: { domain }, + scenario: 'force_media_buy_status', + params: { media_buy_id: created.media_buy_id, status: 'active' }, + }); + expect(rejected).toMatchObject({ success: false, error: 'INVALID_TRANSITION' }); + } finally { + await close(); + } + }); + + it('builds a frozen 3.0 creative from the natural-account library session', async () => { + const { baseUrl, close } = await bootCompatRouter(); + const domain = `compat-creative-${randomUUID()}.example`; + const account = { brand: { domain }, operator: 'pinnacle-agency.example' }; + const creativeId = `compat_video_${randomUUID()}`; + try { + const synced = await callTenantTool(baseUrl, 'creative', 'sync_creatives', { + account, + creatives: [{ + creative_id: creativeId, + name: 'Compatibility video', + format_id: { agent_url: 'https://creative-platform.example', id: 'video_30s' }, + assets: { + video: { + asset_type: 'video', + url: 'https://cdn.pinnacle-agency.example/compat-video.mp4', + width: 1920, + height: 1080, + duration_ms: 30000, + mime_type: 'video/mp4', + }, + }, + }], + idempotency_key: randomUUID(), + }); + expect(synced.adcp_error, JSON.stringify(synced)).toBeUndefined(); + + const built = await callTenantTool(baseUrl, 'creative', 'build_creative', { + account, + creative_id: creativeId, + target_format_id: { agent_url: 'https://creative-platform.example', id: 'vast_30s' }, + idempotency_key: randomUUID(), + }); + expect(built.adcp_error, JSON.stringify(built)).toBeUndefined(); + expect(built.status).toBe('completed'); + expect(built.creative_manifest).toEqual(expect.any(Object)); + } finally { + await close(); + } + }); + + it('keeps opaque account IDs isolated on the frozen 3.0 sales surface', async () => { + const { baseUrl, close } = await bootCompatRouter(); + const suffix = randomUUID(); + const accountA = { account_id: `compat_opaque_a_${suffix}` }; + const accountB = { account_id: `compat_opaque_b_${suffix}` }; + try { + const created = await callTenantTool(baseUrl, 'sales', 'create_media_buy', { + account: accountA, + start_time: '2099-04-01T00:00:00Z', + end_time: '2099-06-30T23:59:59Z', + packages: [{ product_id: 'test-product', pricing_option_id: 'test-pricing', budget: 5000 }], + idempotency_key: randomUUID(), + }); + expect(created.adcp_error, JSON.stringify(created)).toBeUndefined(); + + const crossAccount = await callTenantTool(baseUrl, 'sales', 'comply_test_controller', { + account: accountB, + scenario: 'force_media_buy_status', + params: { media_buy_id: created.media_buy_id, status: 'active' }, + }); + expect(crossAccount).toMatchObject({ success: false, error: 'NOT_FOUND' }); + + const owningAccount = await callTenantTool(baseUrl, 'sales', 'comply_test_controller', { + account: accountA, + scenario: 'force_media_buy_status', + params: { media_buy_id: created.media_buy_id, status: 'active' }, + }); + expect(owningAccount).toMatchObject({ success: true, current_state: 'active' }); + } finally { + await close(); + } + }); + + it('projects frozen 3.0 natural-account fixtures into the legacy brand session', async () => { + const { baseUrl, close } = await bootCompatRouter(); + const domain = `compat-fixture-${randomUUID()}.example`; + const seedAccount = { brand: { domain }, operator: domain, sandbox: true }; + const buyerAccount = { brand: { domain }, operator: 'pinnacle-agency.example', sandbox: true }; + const productId = `compat_product_${randomUUID()}`; + const pricingOptionId = `compat_price_${randomUUID()}`; + try { + const seededProduct = await callTenantTool(baseUrl, 'sales', 'comply_test_controller', { + account: seedAccount, + scenario: 'seed_product', + params: { + product_id: productId, + fixture: { delivery_type: 'non_guaranteed', channels: ['display'] }, + }, + }); + expect(seededProduct.success, JSON.stringify(seededProduct)).toBe(true); + + const seededPricing = await callTenantTool(baseUrl, 'sales', 'comply_test_controller', { + account: seedAccount, + scenario: 'seed_pricing_option', + params: { + product_id: productId, + pricing_option_id: pricingOptionId, + fixture: { pricing_model: 'cpm', currency: 'USD', floor_price: 5 }, + }, + }); + expect(seededPricing.success, JSON.stringify(seededPricing)).toBe(true); + + const created = await callTenantTool(baseUrl, 'sales', 'create_media_buy', { + account: buyerAccount, + brand: { domain }, + start_time: '2099-04-01T00:00:00Z', + end_time: '2099-06-30T23:59:59Z', + packages: [{ product_id: productId, pricing_option_id: pricingOptionId, bid_price: 8, budget: 5000 }], + idempotency_key: randomUUID(), + }); + expect(created.adcp_error, JSON.stringify(created)).toBeUndefined(); + expect(created.media_buy_id).toEqual(expect.any(String)); + } finally { + await close(); + } + }); }); From 352b9e970e7123114e181a0da6fe4e04fdb03888 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 9 Aug 2026 15:34:00 +0200 Subject: [PATCH 08/49] fix(ci): align current account transport --- server/src/training-agent/account-handlers.ts | 10 +++++++--- server/src/training-agent/task-handlers.ts | 15 +++++++++++---- server/src/training-agent/types.ts | 3 +++ server/src/training-agent/v6-sales-platform.ts | 4 ++++ .../integration/training-agent-webhooks.test.ts | 4 ++-- 5 files changed, 27 insertions(+), 9 deletions(-) diff --git a/server/src/training-agent/account-handlers.ts b/server/src/training-agent/account-handlers.ts index 81b005eea6..792ffa7482 100644 --- a/server/src/training-agent/account-handlers.ts +++ b/server/src/training-agent/account-handlers.ts @@ -540,9 +540,13 @@ export function resolveAccountIdForRef( ref: AccountRef | undefined, ): string | undefined { if (!ref) return undefined; - const account = findAccountByRef(getAccountMap(sessionKey, principal), ref) - ?? (ref.account_id ? findAccountByIdAcrossSessions(ref.account_id, principal) : undefined); - return account?.accountId; + for (const accounts of accountMapsForPrincipal(sessionKey, principal)) { + const account = findAccountByRef(accounts, ref); + if (account) return account.accountId; + } + return ref.account_id + ? findAccountByIdAcrossSessions(ref.account_id, principal)?.accountId + : undefined; } export function resolveGovernanceAgentsForAccount( diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 3e778b47c2..6aa00687fb 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -1936,6 +1936,13 @@ async function governedCommitmentError( }; } +function governedRequestPayload( + ctx: TrainingContext, + fallback: Record, +): Record { + return ctx.requestInput ?? fallback; +} + function projectedPackageBudgetTotal(mb: MediaBuyState, req: UpdateMediaBuyArgs): number { const currentBudgets = new Map( mb.packages.map(pkg => [pkg.packageId, pkg.canceled ? 0 : pkg.budget]), @@ -5553,7 +5560,7 @@ export async function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) ctx.authenticatedAgentUrl, 'create_media_buy', `${getCanonicalBase()}/sales`, - req as unknown as Record, + governedRequestPayload(ctx, req as unknown as Record), buyBudget ?? 0, req.total_budget?.currency ?? 'USD', ); @@ -7396,7 +7403,7 @@ export async function handleUpdateMediaBuy(args: ToolArgs, ctx: TrainingContext) ctx.authenticatedAgentUrl, 'update_media_buy', `${getCanonicalBase()}/sales`, - req as unknown as Record, + governedRequestPayload(ctx, req as unknown as Record), updateDelta, mb.currency, ); @@ -8284,7 +8291,7 @@ export async function handleActivateSignal(args: ToolArgs, ctx: TrainingContext) ctx.authenticatedAgentUrl, 'activate_signal', `${getCanonicalBase()}/signals`, - req as unknown as Record, + governedRequestPayload(ctx, req as unknown as Record), signalCommitment, signalCurrency, ); @@ -8596,7 +8603,7 @@ export async function handleBuildCreative(args: ToolArgs, ctx: TrainingContext): ctx.authenticatedAgentUrl, 'build_creative', `${getCanonicalBase()}/${ctx.tenantId === 'creative-builder' ? 'creative-builder' : 'creative'}`, - req as unknown as Record, + governedRequestPayload(ctx, req as unknown as Record), 0, 'USD', ); diff --git a/server/src/training-agent/types.ts b/server/src/training-agent/types.ts index 2d6614d6c0..2127e07285 100644 --- a/server/src/training-agent/types.ts +++ b/server/src/training-agent/types.ts @@ -43,6 +43,9 @@ export interface TrainingContext { * mapping. Never populate this from request arguments or principal text. */ authenticatedAgentUrl?: string; + /** Validated wire input before SDK account extraction; used only to verify + * governance payload bindings against the exact buyer-authorized request. */ + requestInput?: Record; /** Release selected by protocol negotiation for this request. */ servedAdcpVersion?: string; /** Route is the grader-targeted `/mcp-strict` endpoint. Advertises diff --git a/server/src/training-agent/v6-sales-platform.ts b/server/src/training-agent/v6-sales-platform.ts index d9a9573dff..f95cba8cc4 100644 --- a/server/src/training-agent/v6-sales-platform.ts +++ b/server/src/training-agent/v6-sales-platform.ts @@ -111,6 +111,7 @@ function buildTrainingCtx( account?: { authInfo?: { principal?: string } }; authInfo?: { clientId?: string }; agent?: { agent_url: string }; + input?: unknown; } | undefined, storyboardCompat?: TrainingContext['storyboardCompat'], ): TrainingContext { @@ -119,6 +120,9 @@ function buildTrainingCtx( tenantId: 'sales', principal: ctx?.authInfo?.clientId ?? ctx?.account?.authInfo?.principal ?? 'anonymous', ...(ctx?.agent?.agent_url && { authenticatedAgentUrl: ctx.agent.agent_url }), + ...(ctx?.input && typeof ctx.input === 'object' && !Array.isArray(ctx.input) + ? { requestInput: ctx.input as Record } + : {}), ...(storyboardCompat && { storyboardCompat }), }; } diff --git a/server/tests/integration/training-agent-webhooks.test.ts b/server/tests/integration/training-agent-webhooks.test.ts index 7358807e9c..d29f982703 100644 --- a/server/tests/integration/training-agent-webhooks.test.ts +++ b/server/tests/integration/training-agent-webhooks.test.ts @@ -646,7 +646,7 @@ describe('Training Agent webhook emission', () => { }); await callTool(12, 'comply_test_controller', { - account: { sandbox: true }, + account: { ...account, sandbox: true }, brand: account.brand, scenario: 'force_creative_status', params: { @@ -788,7 +788,7 @@ describe('Training Agent webhook emission', () => { }); await callTool(22, 'comply_test_controller', { - account: { sandbox: true }, + account: { brand, operator: 'agency-one.example', sandbox: true }, brand, scenario: 'force_creative_status', params: { From fe17f4e4709b3db7df000e35d0ef0771d0901458 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 9 Aug 2026 18:23:24 +0200 Subject: [PATCH 09/49] fix(training): allow concurrent product discovery --- server/src/training-agent/task-handlers.ts | 99 +++++++++++++++---- ...aining-agent-get-products-rejected.test.ts | 28 ++++++ .../unit/training-agent-idempotency.test.ts | 75 ++++++++++++++ 3 files changed, 183 insertions(+), 19 deletions(-) diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 6aa00687fb..890c6eb621 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -103,6 +103,10 @@ type GetProductsRejectedResponse = { suggestions?: string[]; context?: Record; }; +type GetProductsReadDirectives = { + rejection?: { reason: string; suggestions?: string[] }; + staleDirective?: { tool: string; upstreamName?: string; createdAt: string }; +}; type PricingOption = Product['pricing_options'][number]; type PricingStructure = 'fixed' | 'auction' | 'contingent'; type PricingOptionView = { @@ -3899,16 +3903,32 @@ export async function handleGetProducts(args: ToolArgs, ctx: TrainingContext): P }; } - // Session persistence is whole-document last-writer-wins, so every - // get_products execution must share one session-scoped mutex. Locking only - // finalized proposal IDs still lets a concurrent brief/wholesale request - // overwrite the committed proposal written by a finalization request. + const buyingMode = req.buying_mode ?? 'brief'; const sessionScope = getProductsSessionKeyFromArgs(args, ctx.mode, ctx.userId, ctx.moduleId); const sessionHash = createHash('sha256').update(sessionScope).digest('hex'); const principal = 'get-products-session-mutex'; const key = `get-products-session:${sessionHash}`; const store = getIdempotencyStore(); - const claim = await store.check({ principal, key, payload: { session: sessionHash } }); + let claim = await store.check({ principal, key, payload: { session: sessionHash } }); + + // Read discovery only needs the mutex for a short directive-consumption + // preflight. Queue behind an active writer/preflight instead of surfacing a + // spurious CONFLICT to compare_media_kit-style fan-out. Refine requests keep + // the existing non-blocking conflict behavior because the whole operation + // mutates proposal/pricing state. + const readLockDeadline = Date.now() + 1_000; + let readLockBackoffMs = 5; + while (buyingMode !== 'refine' && claim.kind !== 'miss') { + const remainingMs = readLockDeadline - Date.now(); + if (remainingMs <= 0) break; + const jitterMs = Math.floor(Math.random() * Math.max(1, readLockBackoffMs / 2)); + await new Promise(resolve => setTimeout( + resolve, + Math.min(readLockBackoffMs + jitterMs, remainingMs), + )); + claim = await store.check({ principal, key, payload: { session: sessionHash } }); + readLockBackoffMs = Math.min(readLockBackoffMs * 2, 100); + } if (claim.kind !== 'miss') { return { errors: [{ @@ -3918,10 +3938,39 @@ export async function handleGetProducts(args: ToolArgs, ctx: TrainingContext): P }], }; } + + if (buyingMode !== 'refine') { + let directives: GetProductsReadDirectives = {}; + try { + const session = await getSession( + sessionScope, + controllerFixtureSessionKey(req, ctx), + ); + const directivePrincipal = ctx.principal ?? 'anonymous'; + const rejection = buyingMode === 'brief' && supportsGetProductsRejected(ctx.servedAdcpVersion) + ? session.complyExtensions.forcedGetProductsRejections.get(directivePrincipal) + : undefined; + const staleDirective = session.complyExtensions.forcedUpstreamUnavailable?.tool === 'get_products' + ? session.complyExtensions.forcedUpstreamUnavailable + : undefined; + if (rejection) { + session.complyExtensions.forcedGetProductsRejections.delete(directivePrincipal); + } + if (staleDirective) { + session.complyExtensions.forcedUpstreamUnavailable = undefined; + } + directives = { rejection, staleDirective }; + if (rejection || staleDirective) await flushDirtySessions(); + } finally { + await store.release({ principal, key, claimToken: claim.claimToken }); + } + return handleGetProductsUnlocked(args, ctx, paginationOffset, directives); + } + try { const result = await handleGetProductsUnlocked(args, ctx, paginationOffset); - // Keep the mutex until every mutation made by discovery is durable. This - // includes context changes on non-finalize calls, not just proposal holds. + // Keep the mutex until every refine mutation is durable, not just proposal + // holds, so a following refine request observes the committed context. await flushDirtySessions(); return result; } finally { @@ -3933,6 +3982,7 @@ async function handleGetProductsUnlocked( args: ToolArgs, ctx: TrainingContext, paginationOffset?: number, + readDirectives?: GetProductsReadDirectives, ): Promise { const req = args as unknown as GetProductsRequest & ToolArgs; const buyingMode = req.buying_mode || 'brief'; @@ -3994,11 +4044,15 @@ async function handleGetProductsUnlocked( const contextEcho = req.context ? { context: req.context } : {}; const directivePrincipal = ctx.principal ?? 'anonymous'; - const rejection = buyingMode === 'brief' || buyingMode === 'refine' - ? session.complyExtensions.forcedGetProductsRejections.get(directivePrincipal) - : undefined; + const rejection = readDirectives + ? readDirectives.rejection + : buyingMode === 'brief' || buyingMode === 'refine' + ? session.complyExtensions.forcedGetProductsRejections.get(directivePrincipal) + : undefined; if (rejection && supportsGetProductsRejected(ctx.servedAdcpVersion)) { - session.complyExtensions.forcedGetProductsRejections.delete(directivePrincipal); + if (!readDirectives) { + session.complyExtensions.forcedGetProductsRejections.delete(directivePrincipal); + } return { status: 'rejected', adcp_version: ctx.servedAdcpVersion!, @@ -4498,10 +4552,12 @@ async function handleGetProductsUnlocked( }; }); const canonicalFormatAdvisories = collectCanonicalFormatAdvisories(products); - const staleDirective = session.complyExtensions.forcedUpstreamUnavailable?.tool === 'get_products' - ? session.complyExtensions.forcedUpstreamUnavailable - : undefined; - if (staleDirective) { + const staleDirective = readDirectives + ? readDirectives.staleDirective + : session.complyExtensions.forcedUpstreamUnavailable?.tool === 'get_products' + ? session.complyExtensions.forcedUpstreamUnavailable + : undefined; + if (staleDirective && !readDirectives) { session.complyExtensions.forcedUpstreamUnavailable = undefined; } @@ -4519,10 +4575,15 @@ async function handleGetProductsUnlocked( for (const proposal of retainedCommittedProposals.values()) { if (!persistedProposalIds.has(proposal.proposal_id)) persistedProposals.push(proposal); } - session.lastGetProductsContext = { - products: responseProducts, - proposals: persistedProposals, - }; + // Only refine requests establish durable context for later refinements. + // Brief/wholesale discovery must remain read-only so concurrent reads cannot + // overwrite a proposal committed by a serialized refine request. + if (buyingMode === 'refine') { + session.lastGetProductsContext = { + products: responseProducts, + proposals: persistedProposals, + }; + } let pageProducts = responseProducts; let pagination: { has_more: boolean; total_count: number; cursor?: string } | undefined; if (req.pagination) { diff --git a/server/tests/unit/training-agent-get-products-rejected.test.ts b/server/tests/unit/training-agent-get-products-rejected.test.ts index 9be79aac6d..f4112fa2e6 100644 --- a/server/tests/unit/training-agent-get-products-rejected.test.ts +++ b/server/tests/unit/training-agent-get-products-rejected.test.ts @@ -121,6 +121,34 @@ describe('get_products rejected compliance arm', () => { expect(consumed.data).not.toMatchObject({ status: 'rejected' }); }); + it('atomically consumes a one-shot rejection across parallel brief reads', async () => { + const primaryAccount = account('parallel-rejection-account'); + const reason = 'Only one concurrent request may consume this rejection.'; + const forced = await call('comply_test_controller', { + adcp_version: '3.2-beta.0', + account: primaryAccount, + scenario: 'force_get_products_arm', + params: { arm: 'rejected', reason }, + }); + expect(forced.success).toBe(true); + + const outcomes = await Promise.all([0, 1].map(index => call('get_products', { + adcp_version: '3.2-beta.0', + idempotency_key: `parallel-rejection-${index}-0001`, + account: primaryAccount, + buying_mode: 'brief', + brief: 'Premium video', + }))); + + expect(outcomes.every(outcome => outcome.success)).toBe(true); + expect(outcomes.filter(outcome => ( + outcome.data as { status?: string; reason?: string } + ).status === 'rejected')).toHaveLength(1); + expect(outcomes.find(outcome => ( + outcome.data as { status?: string } + ).status === 'rejected')?.data).toMatchObject({ reason }); + }); + it('rejects empty suggestion arrays instead of emitting a schema-invalid response', async () => { const result = await call('comply_test_controller', { adcp_version: '3.2-beta.0', diff --git a/server/tests/unit/training-agent-idempotency.test.ts b/server/tests/unit/training-agent-idempotency.test.ts index fa8f7a87d8..9718c74027 100644 --- a/server/tests/unit/training-agent-idempotency.test.ts +++ b/server/tests/unit/training-agent-idempotency.test.ts @@ -652,6 +652,81 @@ describe('training agent idempotency middleware', () => { expect(freshProposal).toEqual(firstProposal); }); + it('allows parallel brief discovery reads in the same session', async () => { + const account = { brand: { domain: 'idem-concurrent-brief.example' }, operator: 'idem-op' }; + const payloads = [ + 'cross-channel news video and display', + 'podcast audio inventory', + 'premium streaming video', + ].map((brief, index) => ({ + idempotency_key: `products-brief-${index}-${randomUUID()}`, + buying_mode: 'brief', + brief, + account, + })); + + const outcomes = await Promise.all(payloads.map(payload => call(server, 'get_products', payload))); + + expect(outcomes.every(outcome => outcome.isError !== true)).toBe(true); + expect(outcomes.every(outcome => Array.isArray(outcome.parsed.products))).toBe(true); + }); + + it('does not let concurrent brief discovery conflict with or erase finalization', async () => { + const account = { brand: { domain: 'idem-concurrent-read-finalize.example' }, operator: 'idem-op' }; + const finalizePayload = { + idempotency_key: `products-finalize-${randomUUID()}`, + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', proposal_id: 'pinnacle_cross_channel' }], + }; + const briefPayload = { + idempotency_key: `products-brief-${randomUUID()}`, + buying_mode: 'brief', + brief: 'cross-channel news video and display', + account, + }; + + const [finalized, discovered] = await Promise.all([ + call(server, 'get_products', finalizePayload), + call(server, 'get_products', briefPayload), + ]); + + expect(finalized.isError).toBeFalsy(); + expect(discovered.isError).toBeFalsy(); + const persisted = (await getSession(getProductsSessionKeyFromArgs({ account }, 'open'))) + .lastGetProductsContext?.proposals?.find(proposal => proposal.proposal_id === 'pinnacle_cross_channel'); + expect(persisted).toMatchObject({ proposal_status: 'committed' }); + }); + + it('does not let concurrent wholesale discovery conflict with or erase finalization', async () => { + const account = { brand: { domain: 'idem-concurrent-wholesale-finalize.example' }, operator: 'idem-op' }; + const finalizePayload = { + idempotency_key: `products-finalize-${randomUUID()}`, + buying_mode: 'refine', + account, + refine: [{ scope: 'proposal', action: 'finalize', proposal_id: 'pinnacle_cross_channel' }], + }; + const wholesalePayload = { + idempotency_key: `products-wholesale-${randomUUID()}`, + buying_mode: 'wholesale', + account, + }; + + const [finalized, discovered] = await Promise.all([ + call(server, 'get_products', finalizePayload), + call(server, 'get_products', wholesalePayload), + ]); + + expect(finalized.isError).toBeFalsy(); + expect(discovered.isError).toBeFalsy(); + const finalizedProposal = (finalized.parsed.proposals as Array>) + .find(proposal => proposal.proposal_id === 'pinnacle_cross_channel'); + const persisted = (await getSession(getProductsSessionKeyFromArgs({ account }, 'open'))) + .lastGetProductsContext?.proposals?.find(proposal => proposal.proposal_id === 'pinnacle_cross_channel'); + expect(persisted).toEqual(finalizedProposal); + expect(persisted).toMatchObject({ proposal_status: 'committed' }); + }); + it('serializes parallel proposal-finalize retries into one execution and one replay', async () => { const account = { brand: { domain: 'idem-concurrent-finalize.example' }, operator: 'idem-op' }; await call(server, 'get_products', { From 2a78ebfea74384cd33b89324b1486b2d29c27156 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 10 Aug 2026 05:45:55 +0200 Subject: [PATCH 10/49] fix(protocol): move get_products idempotency to 4.0 --- .changeset/secure-get-products-idempotency.md | 2 +- docs/building/by-layer/L1/security.mdx | 32 ++-- .../verification/compliance-catalog.mdx | 2 +- docs/building/verification/conformance.mdx | 2 +- docs/media-buy/specification.mdx | 4 +- docs/protocol/get_adcp_capabilities.mdx | 2 +- docs/reference/release-notes.mdx | 12 +- docs/reference/whats-new-in-3-1.mdx | 2 +- .../universal/read-tool-idempotency.yaml | 154 +++++++----------- 9 files changed, 92 insertions(+), 120 deletions(-) diff --git a/.changeset/secure-get-products-idempotency.md b/.changeset/secure-get-products-idempotency.md index 3c992cb9c5..81a8741f96 100644 --- a/.changeset/secure-get-products-idempotency.md +++ b/.changeset/secure-get-products-idempotency.md @@ -2,4 +2,4 @@ "adcontextprotocol": major --- -Require idempotency keys for polymorphic `get_products` requests so asynchronous discovery and proposal finalization are safe to retry. +Require idempotency keys in AdCP 4.0 for polymorphic `get_products` requests so asynchronous discovery and proposal finalization are safe to retry. AdCP 3.x retains its bounded omission grace only for guaranteed side-effect-free synchronous reads. diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index c28553b8bc..301e6c1107 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -397,22 +397,22 @@ A daypart with no declared semantics is ambiguous and MUST be rejected with `INV ### Idempotency -`idempotency_key` is **required on every AdCP task request** — read and mutating alike. Keys are scoped per `(authenticated agent, account)` — they have no meaning across agents on the same seller, across accounts under the same agent, or across sellers. Scoping by both dimensions prevents cross-account cache collisions when one agent (e.g. an agency) acts on multiple accounts: an identical-looking `create_media_buy` under account A and account B is two distinct buys, never one cached response replayed across the two. +`idempotency_key` is **required on every state-mutating AdCP task request**. AdCP 4.0 classifies polymorphic `get_products` as state-mutating, so its request schema requires the key on every call even when a particular call completes synchronously as a read. Guaranteed pure-read tasks are not universally required to declare the key, but they MUST accept and apply the replay contract when a caller supplies one. Keys are scoped per `(authenticated agent, account)` — they have no meaning across agents on the same seller, across accounts under the same agent, or across sellers. Scoping by both dimensions prevents cross-account cache collisions when one agent (e.g. an agency) acts on multiple accounts: an identical-looking `create_media_buy` under account A and account B is two distinct buys, never one cached response replayed across the two. -**Enforcement curve.** Sellers MUST reject any **mutating** request that omits `idempotency_key` with `INVALID_REQUEST` from 3.0 onward (unchanged). For **read** requests, the rule phases in across two minors: +**`get_products` enforcement curve.** Sellers MUST reject any **mutating** request that omits `idempotency_key` with `INVALID_REQUEST` from 3.0 onward (unchanged). `get_products` historically looked read-only even though some valid arms mutate state, so its uniform schema requirement uses a major-version boundary: -- **3.1.0** — sellers MUST accept reads that carry `idempotency_key` and process per rules 2–9 (no rejecting on undeclared envelope fields). Sellers SHOULD reject reads that omit it with `INVALID_REQUEST`; sellers MAY accept the omission for the 3.1.x maintenance window **only when the request completes synchronously without creating a task or changing state**. A polymorphic request that would return `Submitted` or commit/finalize state MUST reject an omitted key before performing that effect. -- **3.2.0** — sellers MUST reject reads that omit `idempotency_key` with `INVALID_REQUEST`. The grace window closes at the 3.2 cut. +- **3.1–3.x** — sellers MUST accept `get_products` calls that carry `idempotency_key` and process per rules 2–9. Sellers SHOULD reject calls that omit it with `INVALID_REQUEST`; sellers MAY accept the omission **only when the request completes synchronously without creating a task or changing state**. A call that would return `Submitted` or commit/finalize state MUST reject an omitted key before performing that effect. +- **4.0.0** — the `get_products` request schema requires `idempotency_key` on every call. Sellers MUST reject an omission with `INVALID_REQUEST` before selecting an execution arm. -This staged enforcement lets hand-rolled buyer integrations — built via curl, thin MCP clients, or OpenAPI codegen that doesn't include the field uniformly — migrate over a release window rather than at the 3.1 cut. Buyer SDKs (`@adcp/client`, `adcp-py`) already send `idempotency_key` uniformly today, so SDK-using integrators are unaffected by the cut date. +This staged enforcement lets hand-rolled buyer integrations — built via curl, thin MCP clients, or OpenAPI codegen that doesn't include the field on `get_products` — migrate during 3.x before the 4.0 boundary. Buyer SDKs (`@adcp/client`, `adcp-py`) already send `idempotency_key` uniformly today, so SDK-using integrators are unaffected by the cut date. -**Why universal — including read tools.** Several AdCP tasks are polymorphic. `get_products` is the canonical case: `buying_mode: 'brief'` / `'wholesale'` may complete synchronously (pure read), but the same tool MAY return a `Submitted` envelope when curation requires upstream queries or HITL, and `buying_mode: 'refine'` with `action: 'finalize'` is a commit that transitions a proposal to committed with an `expires_at` hold window (see [refinement guide § Finalize is exclusive](/docs/media-buy/product-discovery/refinement)). Buyers cannot predict at call time whether a given call will be a pure read, an async-task creation, or a commit — so the wire contract requires `idempotency_key` on every call uniformly. For calls that resolve as pure reads, the cache provides byte-stable replay-on-retry within the TTL, which is harmless and gives buyers a uniform retry-safe contract; for calls that resolve as async-task creation or commit, the cache provides the same at-most-once guarantees as on mutating tasks. The alternative — classifying per-call read-vs-mutating in the buyer's SDK — is not feasible when the same task name has both read and write modes. Decoding unknown `error.code` values returned by sellers (whether `INVALID_REQUEST` during the grace window or codes added in later minors) follows the [Forward-compatible decoding](/docs/building/by-layer/L3/error-handling#forward-compatible-decoding-normative) rule. +**Why uniform for `get_products`.** `get_products` is polymorphic: `buying_mode: 'brief'` / `'wholesale'` may complete synchronously (pure read), but the same tool MAY return a `Submitted` envelope when curation requires upstream queries or HITL, and `buying_mode: 'refine'` with `action: 'finalize'` is a commit that transitions a proposal to committed with an `expires_at` hold window (see [refinement guide § Finalize is exclusive](/docs/media-buy/product-discovery/refinement)). Buyers cannot predict at call time whether a given call will be a pure read, an async-task creation, or a commit — so the 4.0 wire contract requires `idempotency_key` on every `get_products` call uniformly. For calls that resolve as pure reads, the cache provides byte-stable replay-on-retry within the TTL, which is harmless and gives buyers a uniform retry-safe contract; for calls that resolve as async-task creation or commit, the cache provides the same at-most-once guarantees as on mutating tasks. The alternative — classifying per-call read-vs-mutating in the buyer's SDK — is not feasible when the same task name has both read and write modes. Decoding unknown `error.code` values returned by sellers (whether `INVALID_REQUEST` during the grace window or codes added in later minors) follows the [Forward-compatible decoding](/docs/building/by-layer/L3/error-handling#forward-compatible-decoding-normative) rule. This section applies only to AdCP task requests. OpenRTB bid streams have their own semantics (`BidRequest.id` is a transaction ID, not an idempotency key) and are out of scope. #### Normative seller behavior -1. **Schema validation runs first.** Sellers MUST validate the request against its schema (including presence and format of `idempotency_key`) BEFORE consulting the idempotency cache. The sole 3.1.x migration exception is an omitted key on a request that the seller can guarantee will complete as a side-effect-free synchronous read; this exception never permits `Submitted`, task allocation, or a state transition. A malformed request returns `INVALID_REQUEST` without ever touching the cache — otherwise cache misses become a timing side channel that leaks whether schema validation accepted the key format. Validation errors are never cached (per rule 2). +1. **Schema validation runs first.** Sellers MUST validate the request against its schema (including presence and format of `idempotency_key` when declared) BEFORE consulting the idempotency cache. The sole 3.x `get_products` migration exception is an omitted key on a request that the seller can guarantee will complete as a side-effect-free synchronous read; this exception never permits `Submitted`, task allocation, or a state transition and is removed in 4.0. A malformed request returns `INVALID_REQUEST` without ever touching the cache — otherwise cache misses become a timing side channel that leaks whether schema validation accepted the key format. Validation errors are never cached (per rule 2). 2. **First call is canonical.** On **task success** (`status: completed` or `status: submitted` for async operations), the seller stores the inner response payload (not the protocol envelope) keyed by `(authenticated_agent, account_id, idempotency_key)` along with a hash of the canonical request payload. **The cache entry is immutable** — replays within the TTL MUST return the originally-cached payload (with `replayed: true`), and state-tracking fields in that payload MUST NOT be refreshed to reflect the resource's current state. This rule applies across both success branches: - **Async tasks** — the cached response is the `submitted` result containing `task_id`. Even if the async task subsequently completes, fails, or is canceled, a replay MUST return the originally-cached `submitted` response, NOT the current terminal state. The buyer uses the returned `task_id` to observe current state via `tasks/get` or webhook, exactly as it would have on the first call. @@ -428,13 +428,13 @@ This section applies only to AdCP task requests. OpenRTB bid streams have their 7. **Replay window is declared, not inferred.** Sellers MUST declare `capabilities.idempotency.replay_ttl_seconds` on `get_adcp_capabilities` (minimum 3600s / 1h, recommended 86400s / 24h, maximum 604800s / 7d). Clients MUST NOT fall back to an assumed default — a seller with no declaration is non-compliant and MUST be treated as unsafe for retry-sensitive operations. 8. **Cache-growth defense.** Sellers MUST apply per-`(authenticated_agent, account)` rate limits on idempotency cache inserts separately from request rate limits, and MUST return `RATE_LIMITED` (see [error taxonomy](/docs/building/by-layer/L3/error-handling#rate-limit-handling)) when the per-agent insert rate exceeds the configured ceiling rather than let the cache grow unbounded. A buyer submitting N fresh keys per second on a cheap success-path operation (e.g., `log_event`) would otherwise force unbounded storage, with amplification proportional to `replay_ttl_seconds` at the 3600 s floor. The natural bound is `inserts_per_hour × replay_ttl_hours ≤ max_cache_rows_per_agent`. - **Recommended ceilings (3.1+):** the original 60/sec sustained / 300/sec burst single-budget ceiling was sized against a write-heavy launch pattern (≤10 media buys/min × 10 packages × 10 creatives with 3–5× headroom). Under universal idempotency, read traffic also contributes to insert rate — a single agentic dashboard polling `get_products(brief)` + `list_creatives` + `list_accounts` across 5 accounts at 1Hz is ~15 inserts/sec on reads alone, before any write activity. Operators SHOULD adopt a **split budget** per `(authenticated_agent, account)`: + **Recommended ceilings (3.1+):** the original 60/sec sustained / 300/sec burst single-budget ceiling was sized against a write-heavy launch pattern (≤10 media buys/min × 10 packages × 10 creatives with 3–5× headroom). When callers key read traffic, those responses also contribute to insert rate — a single agentic dashboard polling `get_products(brief)` + `list_creatives` + `list_accounts` across 5 accounts at 1Hz is ~15 inserts/sec on reads alone, before any write activity. Operators that cache keyed reads SHOULD adopt a **split budget** per `(authenticated_agent, account)`: - **Reads: 300 inserts/sec sustained, 1,500/sec burst over rolling 10s windows.** Dominated by dashboard polling and agentic state re-reads under the [Polling / state re-read](#agent-retry-vs-polling-vs-re-plan) rule. Read traffic is typically bursty during user-driven UI interactions and steady at low rates during agent runs. - **Writes: 60 inserts/sec sustained, 300/sec burst.** Unchanged from the original write-heavy sizing — preserved as a separate budget so a buyer's dashboard polling can't exhaust the write capacity that protects `create_media_buy` / `sync_creatives` / `activate_signal` from double-execution races. - **Combined cap (defense in depth):** total inserts SHOULD NOT exceed 350/sec sustained / 1,700/sec burst per agent — the sum of the two budgets with a small cushion, so an attacker who saturates the read budget cannot starve write capacity. - Operators with steady low-volume traffic MAY tighten below these starting values; operators with burst onboarding or trafficking patterns larger than this ceiling MUST raise rather than accept silent rejection of legitimate traffic. The split-budget shape (separate read and write counters) MUST be implemented from 3.1 onward even when operators tighten the magnitudes — a shared single-budget cap is the failure mode this rule prevents. The sustained bound is a rolling 60-second window — a burst that empties a 10-second window still counts toward the next 50 seconds of the 60-second rolling bound. Sellers that adopt a different window shape (fixed-minute bucket, EWMA) MUST document it so buyers with retry logic can predict when `RATE_LIMITED` fires; silent window-shape divergence between sellers means identical buyer traffic passes one seller and is rejected by another on conformant implementations. At the 3600 s TTL floor the combined-cap rates bound per-agent residency to ~1.26M entries — an order of magnitude above the original 216k from the write-only sizing, reflecting the read-traffic addition; per-agent storage budgeting should account for this. The numeric recommendations are SHOULD-level; the rate-limit-and-reject-with-`RATE_LIMITED` behavior itself is MUST. Sellers MUST expose the ceilings as tunable configuration parameters — the 300/60 read/write split numbers are first-deployment starting points for an agentic-buyer dashboard pattern, not frozen defaults. Sellers SHOULD NOT publish exact configured ceiling numerics in capability responses — doing so makes the ceiling an ecosystem-wide attack target. Buyers discover the effective ceilings through the `RATE_LIMITED` + `retry_after` response, not through capability introspection. + Operators with steady low-volume traffic MAY tighten below these starting values; operators with burst onboarding or trafficking patterns larger than this ceiling MUST raise rather than accept silent rejection of legitimate traffic. Sellers that cache keyed reads MUST keep separate read and write counters even when they tighten the magnitudes — a shared single-budget cap is the failure mode this rule prevents. The sustained bound is a rolling 60-second window — a burst that empties a 10-second window still counts toward the next 50 seconds of the 60-second rolling bound. Sellers that adopt a different window shape (fixed-minute bucket, EWMA) MUST document it so buyers with retry logic can predict when `RATE_LIMITED` fires; silent window-shape divergence between sellers means identical buyer traffic passes one seller and is rejected by another on conformant implementations. At the 3600 s TTL floor the combined-cap rates bound per-agent residency to ~1.26M entries — an order of magnitude above the original 216k from the write-only sizing, reflecting the read-traffic addition; per-agent storage budgeting should account for this. The numeric recommendations are SHOULD-level; the rate-limit-and-reject-with-`RATE_LIMITED` behavior itself is MUST. Sellers MUST expose the ceilings as tunable configuration parameters — the 300/60 read/write split numbers are first-deployment starting points for an agentic-buyer dashboard pattern, not frozen defaults. Sellers SHOULD NOT publish exact configured ceiling numerics in capability responses — doing so makes the ceiling an ecosystem-wide attack target. Buyers discover the effective ceilings through the `RATE_LIMITED` + `retry_after` response, not through capability introspection. The ceiling is per `(authenticated_agent, account)` — the same scope as the idempotency key itself (bullet 1) — so a multi-account agency does not have its per-account budgets collapsed into a single shared quota. `RATE_LIMITED` rejections MUST populate `retry_after` (seconds) per the [error handling taxonomy](/docs/building/by-layer/L3/error-handling#rate-limit-handling) and MUST NOT be cached as idempotency responses (rule 3: only successful responses are cached). Sellers SHOULD enforce `retry_after` as a cheap rejection floor — a buyer retrying before `retry_after` elapses SHOULD hit a pre-auth token bucket (e.g., at a reverse-proxy layer) rather than re-entering the full schema-validate-and-cache-check pipeline on every retry. Without this discipline, misbehaving buyers can amplify load on the rate-limiter itself. @@ -494,7 +494,7 @@ AdCP SDK middleware ships JCS canonicalization so sellers don't roll their own. Buyer SDKs send envelope-level fields (`idempotency_key`, `context_id`, `context`, `governance_context`, `push_notification_config`) **uniformly across all AdCP tool calls** — buyers cannot know per-tool which envelope fields the seller's wrapper happens to declare. Servers MUST tolerate envelope-level fields that arrive in tool params but are not declared in the tool's parameter schema. Concretely: -- **`idempotency_key`** is required on every AdCP task request (see rule 1 above — read and mutating alike). Tool wrappers MUST accept it; the idempotency layer routes it per rules 2-9. Wrappers that reject the field with `unexpected_keyword_argument` (FastMCP/Pydantic strict signatures) are non-conformant. +- **`idempotency_key`** is required on state-mutating task schemas and on every AdCP 4.0 `get_products` request. Guaranteed pure-read schemas may leave it optional, but tool wrappers MUST accept it when supplied; the idempotency layer routes it per rules 2-9. Wrappers that reject the field with `unexpected_keyword_argument` (FastMCP/Pydantic strict signatures) are non-conformant. - **`context_id`, `context`, `push_notification_config`, `governance_context`** MUST be accepted on every tool, including reads. Tools that don't consume a given field MUST ignore it; they MUST NOT reject the call because the envelope field is present. This is the server-side counterpart to the `additionalProperties: true` default that every published AdCP request schema declares. Configuring a server-side validator in a way that contradicts the schema's own `additionalProperties` declaration is a conformance violation. Common server-implementation traps: @@ -564,31 +564,31 @@ Leaking cached state turns key-reuse into a read oracle. An attacker who guesses #### Buyer obligations -Buyers MUST generate a unique `idempotency_key` per `(seller, request)` pair. Reusing the same key across sellers allows colluding sellers to correlate requests from the same buyer. Use a fresh UUID v4 for each request. On retry after a network error, buyers MUST resend the exact same payload with the same key — changing either side breaks at-most-once semantics. In particular, buyers MUST NOT change `push_notification_config.url` between retries with the same key; URL is part of the canonical hash and rotating it triggers `IDEMPOTENCY_CONFLICT`. Rotate the key when changing webhook configuration. +Whenever a buyer sends an `idempotency_key`, it MUST generate a unique key per `(seller, request)` pair. Reusing the same key across sellers allows colluding sellers to correlate requests from the same buyer. Use a fresh UUID v4 for each keyed request. On retry after a network error, buyers MUST resend the exact same payload with the same key — changing either side breaks at-most-once semantics. In particular, buyers MUST NOT change `push_notification_config.url` between retries with the same key; URL is part of the canonical hash and rotating it triggers `IDEMPOTENCY_CONFLICT`. Rotate the key when changing webhook configuration. **Network retry vs. agent re-plan vs. polling / state re-read.** Three cases that look similar but need different handling: - **Network retry** — socket timeout, 5xx, transient failure. The buyer has the *same intent* and sent the *same bytes* — and MUST resend them with the *same key*. This is what idempotency_key exists for. - **Agent re-plan** — the buyer is an agent whose planner re-ran (prompt re-executed, tool output changed, policy re-evaluated) and produced a *different payload*. The intent has changed. The agent MUST mint a *new key* and treat the prior request as abandoned. Reusing the prior key with a different canonical payload returns `IDEMPOTENCY_CONFLICT`, which is the seller correctly telling the agent "you're not retrying, you're doing something new." -- **Polling / state re-read** — a dashboard polling `get_products(brief)`, `list_creatives`, `list_accounts` at intervals; a buyer agent reading `get_media_buys` to fetch fresh state after a mutation; any "give me current state at time T" call. Buyers MUST mint a fresh `idempotency_key` per call. Reusing the prior poll's key would replay the cached snapshot (up to `replay_ttl_seconds`), silently returning stale data — exactly the failure mode the cache exists to prevent on mutations. This rule also governs the re-read step in the [Replay responses are historical snapshots](#replay-responses-are-historical-snapshots) pattern below: the "re-read for current state" call MUST carry a fresh key, never the key from the mutation it's reading state for. +- **Polling / state re-read** — a dashboard polling `get_products(brief)`, `list_creatives`, `list_accounts` at intervals; a buyer agent reading `get_media_buys` to fetch fresh state after a mutation; any "give me current state at time T" call. When the task schema requires a key or the buyer elects to send one, the buyer MUST mint a fresh `idempotency_key` per call. Reusing the prior poll's key would replay the cached snapshot (up to `replay_ttl_seconds`), silently returning stale data — exactly the failure mode the cache exists to prevent on mutations. This rule also governs the re-read step in the [Replay responses are historical snapshots](#replay-responses-are-historical-snapshots) pattern below: a keyed "re-read for current state" call MUST carry a fresh key, never the key from the mutation it's reading state for. When in doubt, ask whether the buyer's intent is **"give me the same answer as before"** (network retry — reuse the key) or **"give me the current answer"** (polling / state re-read — mint a new key) or **"do this new thing"** (agent re-plan — mint a new key). Agentic clients that loop through an LLM to build the request SHOULD freeze and cache the serialized bytes alongside the key on first send for the network-retry case, so retries send the identical payload even if the planner would produce something slightly different on re-execution. -**Bootstrap carve-out — `get_adcp_capabilities`.** The discovery call itself is exempt from rules 1–9 of this section. `get_adcp_capabilities` is how the buyer learns whether the seller declares `adcp.idempotency.replay_ttl_seconds`, so a fail-closed rule against the discovery call would deadlock the bootstrap. Buyers MAY omit `idempotency_key` on `get_adcp_capabilities`, and sellers MUST accept the call without it. Buyers that send `idempotency_key` on `get_adcp_capabilities` (e.g., SDKs that include the field uniformly) get the standard cache behavior — but the discovery call carries no state and replay is harmless. Every other AdCP task request remains subject to rules 1–9; the fail-closed obligation below applies once the capability fetch has completed. +**Bootstrap carve-out — `get_adcp_capabilities`.** The discovery call itself is exempt from any schema-level key requirement. `get_adcp_capabilities` is how the buyer learns whether the seller declares `adcp.idempotency.replay_ttl_seconds`, so a fail-closed rule against the discovery call would deadlock the bootstrap. Buyers MAY omit `idempotency_key` on `get_adcp_capabilities`, and sellers MUST accept the call without it. Buyers that send `idempotency_key` on `get_adcp_capabilities` (e.g., SDKs that include the field uniformly) get the standard cache behavior — but the discovery call carries no state and replay is harmless. Every task request that carries a key remains subject to rules 1–9; the fail-closed obligation below applies to retry-sensitive operations once the capability fetch has completed. -**When the seller's capability declaration is missing.** A seller whose `get_adcp_capabilities` response omits `adcp.idempotency.replay_ttl_seconds` is non-compliant. After a successful capability fetch, client SDKs MUST fail closed on every subsequent AdCP task request against that seller — raise an error, don't assume a default — so the buyer learns about the non-compliance immediately rather than after a silent double-booking. The fail-closed rule applies to every AdCP task request (other than `get_adcp_capabilities` itself) now that `idempotency_key` is required universally — including calls that resolve as pure reads, because the buyer cannot predict at call time whether a polymorphic task (`get_products` brief vs. refine+finalize vs. async-Submitted) will resolve as a read or a mutation, and the missing TTL declaration means the seller is unsafe to retry against in any mode. +**When the seller's capability declaration is missing.** A seller whose `get_adcp_capabilities` response omits `adcp.idempotency.replay_ttl_seconds` is non-compliant. After a successful capability fetch, client SDKs MUST fail closed before any state-mutating task and before every AdCP 4.0 `get_products` call against that seller — raise an error, don't assume a default — so the buyer learns about the non-compliance before a silent double-booking. A guaranteed pure-read task whose schema leaves the key optional MAY proceed without replay protection, but the client MUST NOT silently treat a retry as deduplicated. **Decoding seller-emitted error codes.** Sellers MAY return error codes (`IDEMPOTENCY_CONFLICT`, `IDEMPOTENCY_EXPIRED`, `IDEMPOTENCY_IN_FLIGHT`, `INVALID_REQUEST`, or codes added in later minor versions) that buyers' pinned vocabulary may not recognize. Receivers MUST decode these per [Forward-compatible decoding (normative)](/docs/building/by-layer/L3/error-handling#forward-compatible-decoding-normative) — read `error.recovery` for the recovery classification, default to `transient` when `recovery` is absent, and never reject the response because the code value is unfamiliar. The retry semantics for `transient`-classified errors are bounded by [§ Retry Logic](/docs/building/by-layer/L3/error-handling#retry-logic) (`maxRetries` and exponential backoff with jitter) — buyers MUST NOT loop indefinitely on a `transient` default. **Replay responses are historical snapshots.** A response carrying `replayed: true` is byte-equivalent to the original first-call response (per seller rule 2) — state-tracking fields in it reflect the resource's state at first-call time, NOT the resource's current state. A buyer that reads `status: pending_creatives` from a replayed `create_media_buy` response and then calls `update_media_buy(canceled: true)` on a resource that has actually been in `canceled` for hours will surface a `NOT_CANCELLABLE` error and a state-machine bug. Buyers requiring current state MUST consult the resource's read endpoint — `get_media_buys` for media buys, `list_accounts` for accounts, `list_creatives` for creatives, `get_signals` for signals, equivalents for other resources. `replayed: true` is the explicit signal that a fresh read is required before any state-dependent decision; SDKs SHOULD surface the flag to caller code rather than transparently unwrap it. Agentic buyers MUST treat `replayed: true` as a stop signal for any planning step whose next action depends on resource state, and MUST re-read before continuing. -**The re-read MUST carry a fresh `idempotency_key`.** Reusing the key from the mutation whose state you're re-reading either returns `IDEMPOTENCY_CONFLICT` (if the read payload differs from the mutation payload — almost always true) or, worse, returns the cached mutation response itself (if the payloads happen to match). Reusing a *prior read's* key returns that prior read's cached snapshot — the exact stale-state failure mode this rule exists to prevent. State re-reads fall under the Polling / state re-read case above; mint a new key per call. +**A keyed re-read MUST carry a fresh `idempotency_key`.** Reusing the key from the mutation whose state you're re-reading either returns `IDEMPOTENCY_CONFLICT` (if the read payload differs from the mutation payload — almost always true) or, worse, returns the cached mutation response itself (if the payloads happen to match). Reusing a *prior read's* key returns that prior read's cached snapshot — the exact stale-state failure mode this rule exists to prevent. State re-reads fall under the Polling / state re-read case above; mint a new key per keyed call. **TTL boundary for persisted keys.** Some buyers persist `idempotency_key` alongside their own object (e.g., `campaign.pending_idempotency_key` in the buyer's DB) so that retries after a process restart or overnight reconcile still dedup. This works **only within the seller's declared `replay_ttl_seconds`**. Beyond the TTL, the seller will either reject the retry with `IDEMPOTENCY_EXPIRED` (good) or, if the cache was evicted, treat it as a new request (silent double-booking — the failure mode this field exists to prevent). Buyers retrying past the TTL MUST fall back to a natural-key check (e.g., query `get_media_buys` by `context.internal_campaign_id`) before resending. The `idempotency_key` guarantees at-most-once execution within the replay window, not forever. Queue-based retry systems and workflow engines with retry horizons longer than the seller's TTL MUST be designed around this — don't put a key into a dead-letter queue that replays days later without a natural-key re-check. **Keys are security-sensitive.** An `idempotency_key` is a secret capability token within its TTL — anyone who holds one and knows the original payload can replay it and read the cached response. Treat keys the way you treat session tokens: do not log them in full, do not embed them in URLs, do not share them across agents. Log prefix-only (first 8 chars of the UUID) if you need correlation. Buyers persisting `pending_idempotency_key` at rest (e.g., alongside a campaign row in the buyer's DB) MUST encrypt it with the same controls used for bearer tokens, and SHOULD purge the key after success confirmation to minimize the exposure window. -**Sellers MUST encrypt the cache tier at rest.** Under universal idempotency (3.1+), the cache holds read-tool responses (`get_products`, `list_accounts`, `list_creatives`, `get_signals`, etc.) in addition to the write receipts it held in 3.0.x. Those read responses carry account-scoped data — brand domains, account names, product allocations, signal references — at the same sensitivity as the seller's underlying resource store. Sellers MUST apply at-rest encryption to the idempotency cache with the same controls used for the resource store the cached data was read from, MUST NOT treat the cache as a transient retry-receipt store exempt from data-at-rest controls, and MUST scope cache reads by `(authenticated_agent, account_id)` at the storage layer (not just at the application layer) so a misconfigured query cannot pull a sibling tenant's cached read response. +**Sellers MUST encrypt the cache tier at rest.** When sellers cache keyed read-tool responses (`get_products`, `list_accounts`, `list_creatives`, `get_signals`, etc.), those entries join the write receipts already held by the cache. Read responses carry account-scoped data — brand domains, account names, product allocations, signal references — at the same sensitivity as the seller's underlying resource store. Sellers MUST apply at-rest encryption to the idempotency cache with the same controls used for the resource store the cached data was read from, MUST NOT treat the cache as a transient retry-receipt store exempt from data-at-rest controls, and MUST scope cache reads by `(authenticated_agent, account_id)` at the storage layer (not just at the application layer) so a misconfigured query cannot pull a sibling tenant's cached read response. **Keys MUST be unguessable.** Schema enforces `^[A-Za-z0-9_.:-]{16,255}$` and buyers MUST use UUID v4 (~122 bits of entropy) or an equivalent CSPRNG-generated value. Low-entropy keys like `retry-001` or monotonic counters turn the cache into an enumerable surface: an attacker can walk the key space and test each one against a target agent. Sellers SHOULD reject keys that fail a basic entropy check (e.g., all-zeros, repeated characters, short ASCII words) with `INVALID_REQUEST` when the authenticated agent is not individually trusted. diff --git a/docs/building/verification/compliance-catalog.mdx b/docs/building/verification/compliance-catalog.mdx index 95b000a44e..1764ea62e4 100644 --- a/docs/building/verification/compliance-catalog.mdx +++ b/docs/building/verification/compliance-catalog.mdx @@ -31,7 +31,7 @@ Every agent runs every storyboard in `/compliance/{version}/universal/` regardle | `error-compliance-signals` | Error handling for signals protocol — nonexistent signal IDs, missing fields, VERSION_UNSUPPORTED, transport binding; discovery phases are gated on `get_signals`; activation phases run only for agents that claim activation support such as `signal-marketplace` | | `stale-response-advisory` | `STALE_RESPONSE` wire placement — advisory rides in `errors[]` on a populated success response with transport success preserved; stale-cache forcing steps gated on `comply_test_controller` with `force_upstream_unavailable` | | `idempotency` | `idempotency_key` scoping, replay semantics, `IDEMPOTENCY_CONFLICT`, `replayed: true`, declared TTL | -| `read-tool-idempotency` | Read-only task wrappers accept the 3.1 every-request `idempotency_key` envelope without strict request-wrapper rejection; includes the 3.1 omitted-key grace probe. | +| `read-tool-idempotency` | Read-only task wrappers accept an optional `idempotency_key` without strict request-wrapper rejection; verifies the keyless capabilities bootstrap and 4.0 `get_products` missing-key rejection. | | `canonical-format-validate-input` | Canonical-format `validate_input` result semantics — structural pass/fail across required slots and `unvalidatable_nondeterministic` for seeded product declarations. Gated on agents that advertise `validate_input`; the seeded-product branch also requires `comply_test_controller` seeding support. | | `security` | **Authentication baseline — unauth rejection, static-credential enforcement (Bearer API key or HTTP Basic), OAuth discovery + RFC 9728 audience binding.** See [Authentication](/docs/building/by-layer/L2/authentication). | | `oauth-setup` | Complete RFC 9728 protected-resource and RFC 8414 authorization-server metadata graph — resource/issuer consistency, every referenced authorization server, and passive endpoint reachability. Gated on `oauth.supported: true`; RFC 7591 registration transactions are out of scope. | diff --git a/docs/building/verification/conformance.mdx b/docs/building/verification/conformance.mdx index 5dde7a8a0b..0af08ef010 100644 --- a/docs/building/verification/conformance.mdx +++ b/docs/building/verification/conformance.mdx @@ -104,7 +104,7 @@ Every agent MUST pass every storyboard below. | [`error_compliance_signals`](https://adcontextprotocol.org/compliance/latest/universal/error-compliance-signals) | Error handling for signals protocol — nonexistent signal IDs, missing fields, VERSION_UNSUPPORTED, transport binding; discovery phases are gated on `get_signals`; activation phases run only for agents that claim activation support such as `signal-marketplace` | | [`stale_response_advisory`](https://adcontextprotocol.org/compliance/latest/universal/stale-response-advisory) | `STALE_RESPONSE` wire placement — advisory rides in `errors[]` on a populated success response with transport success preserved; stale-cache forcing steps gated on `comply_test_controller` with `force_upstream_unavailable` | | [`idempotency`](https://adcontextprotocol.org/compliance/latest/universal/idempotency) | `idempotency_key` scoping, replay semantics, `IDEMPOTENCY_CONFLICT`, `replayed: true`, declared TTL | -| [`read_tool_idempotency`](https://adcontextprotocol.org/compliance/latest/universal/read-tool-idempotency) | Read-only task wrappers accept the 3.1 every-request `idempotency_key` envelope without strict request-wrapper rejection; includes the 3.1 omitted-key grace probe | +| [`read_tool_idempotency`](https://adcontextprotocol.org/compliance/latest/universal/read-tool-idempotency) | Read-only task wrappers accept an optional `idempotency_key` without strict request-wrapper rejection; verifies the keyless capabilities bootstrap and 4.0 `get_products` missing-key rejection | | [`canonical_format_validate_input`](https://adcontextprotocol.org/compliance/latest/universal/canonical-format-validate-input) | Canonical-format `validate_input` result semantics: structural pass/fail across required slots and `unvalidatable_nondeterministic` for seeded product declarations. Gated on agents that advertise `validate_input`; the seeded-product branch also requires `comply_test_controller` seeding support. | | [`security_baseline`](https://adcontextprotocol.org/compliance/latest/universal/security) | Unauth rejection, static-credential enforcement (Bearer API key or HTTP Basic), OAuth discovery + RFC 9728 audience binding | | [`oauth_setup`](https://adcontextprotocol.org/compliance/latest/universal/oauth-setup) | Complete RFC 9728/RFC 8414 discovery graph, issuer/resource consistency, and passive endpoint reachability — skipped unless `oauth.supported: true` | diff --git a/docs/media-buy/specification.mdx b/docs/media-buy/specification.mdx index a46011e235..18cea122b7 100644 --- a/docs/media-buy/specification.mdx +++ b/docs/media-buy/specification.mdx @@ -548,13 +548,13 @@ Sales agents SHOULD target the following response times: ### Idempotency -Every AdCP task request MUST carry an `idempotency_key`, including `get_products`. Sales agents MUST apply the universal replay contract: +Every state-mutating AdCP task request MUST carry an `idempotency_key`. In AdCP 4.0 that includes every `get_products` call because the tool is polymorphic. Sales agents MUST apply the replay contract: - The same key and equivalent canonical payload within the replay window MUST return the original response without re-executing the request. - The same key with a different canonical payload MUST return `IDEMPOTENCY_CONFLICT`. - Keys are scoped to the authenticated caller and account so retries cannot cross tenant boundaries. -The 3.1.x migration grace applies only when a seller can guarantee that an omitted-key request will complete as a side-effect-free synchronous read. It never permits a `Submitted` response, task allocation, proposal finalization, or another state transition. `get_products` is polymorphic, so buyers MUST include the key on every call rather than attempting to predict which execution arm the seller will choose. See [Request safety — idempotency](/docs/building/by-layer/L1/security#idempotency) for the normative cache, replay, concurrency, and 3.2 enforcement rules. +The 3.x `get_products` migration grace applies only when a seller can guarantee that an omitted-key request will complete as a side-effect-free synchronous read. It never permits a `Submitted` response, task allocation, proposal finalization, or another state transition, and it is removed in AdCP 4.0. `get_products` is polymorphic, so buyers MUST include the key on every call rather than attempting to predict which execution arm the seller will choose. See [Request safety — idempotency](/docs/building/by-layer/L1/security#idempotency) for the normative cache, replay, concurrency, and 4.0 enforcement rules. ### Human-in-the-Loop diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index 00fa6f20c6..acc44f919d 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -90,7 +90,7 @@ Core AdCP protocol information: #### idempotency -Declares whether this seller honors `idempotency_key` replay protection. From 3.1 onward `idempotency_key` is required on every AdCP task request (read and mutating alike — staged enforcement for reads: SHOULD-reject in 3.1, MUST-reject in 3.2; see [security.mdx § Idempotency](/docs/building/by-layer/L1/security#idempotency)). Mirrors the `request_signing.supported` pattern — a single positive declaration, decoupled from the window detail. Clients MUST NOT assume a default; a seller without this block is non-compliant and should be treated as unsafe for retry-sensitive operations across all call modes. +Declares whether this seller honors `idempotency_key` replay protection. AdCP requires keys on mutating requests and requires sellers to accept them on pure reads when supplied. AdCP 4.0 classifies polymorphic `get_products` as mutating and requires the key on every call; guaranteed pure-read request schemas may continue to leave it optional. See [security.mdx § Idempotency](/docs/building/by-layer/L1/security#idempotency). Mirrors the `request_signing.supported` pattern — a single positive declaration, decoupled from the window detail. Clients MUST NOT assume a default; a seller without this block is non-compliant and should be treated as unsafe for retry-sensitive operations across all call modes. | Field | Type | Description | |-------|------|-------------| diff --git a/docs/reference/release-notes.mdx b/docs/reference/release-notes.mdx index 642ef4f36a..f2730256a8 100644 --- a/docs/reference/release-notes.mdx +++ b/docs/reference/release-notes.mdx @@ -182,12 +182,12 @@ Existing exact durations and closed ranges remain valid. PR [#5323](https://gith ### Wire conformance — idempotency & envelope tolerance (#4399 / #4399b, #2911, #4227, #4371, #4418, #4107, #4196, #4043) -A bundle of normative clarifications that codify deployed behavior and close ambiguities that surfaced from real adopter integrations. None require code changes for SDK-using integrators; hand-rolled MCP clients should review the universal-idempotency staged enforcement curve before the 3.2 cut. +A bundle of normative clarifications that codify deployed behavior and close ambiguities that surfaced from real adopter integrations. None require code changes for SDK-using integrators; hand-rolled MCP clients should add `idempotency_key` to `get_products` before the 4.0 cut. -**`idempotency_key` is required on every AdCP task request — read and mutating alike (#4399b).** The 3.0 contract framed it as mutating-only, but `get_products` is polymorphic (`brief`/`wholesale` may return `Submitted`; `refine`+`finalize` is a commit) and buyers can't classify at call time. Enforcement is **staged**: +**`get_products` requires `idempotency_key` in AdCP 4.0 (#4399b).** The 3.0 contract framed the field as mutating-only, but `get_products` is polymorphic (`brief` may return `Submitted`; `refine`+`finalize` is a commit) and buyers can't classify every call at request-construction time. Migration is **staged**: -- **3.1.0** — sellers MUST accept reads that carry `idempotency_key`; SHOULD reject reads that omit it (MAY accept the omission during the 3.1.x maintenance window only for guaranteed side-effect-free synchronous reads). Calls that return `Submitted` or commit/finalize state are never covered by the omission grace. -- **3.2.0** — sellers MUST reject reads that omit it. +- **3.1–3.x** — sellers MUST accept `get_products` calls that carry `idempotency_key`; SHOULD reject calls that omit it (MAY accept the omission only when that call is guaranteed to complete as a side-effect-free synchronous read). Calls that return `Submitted` or commit/finalize state are never covered by the omission grace. +- **4.0.0** — the `get_products` request schema requires the key on every call. Guaranteed pure-read tasks may continue to leave it optional. The cache holds read responses too from 3.1 onward — sellers MUST encrypt the cache tier at rest with the same controls applied to the underlying resource store. Operators with read-heavy buyer mixes SHOULD raise the rule-8 insert ceiling (sized originally against write-heavy traffic). See [security.mdx § Idempotency](/docs/building/by-layer/L1/security#idempotency) for the full contract. @@ -212,7 +212,7 @@ The cache holds read responses too from 3.1 onward — sellers MUST encrypt the | If you are… | What you need to do | |---|---| | A buyer using `@adcp/client` or `adcp-py` | Nothing. Both SDKs already send `idempotency_key` uniformly on every call. | -| A buyer with hand-rolled MCP clients (curl, thin clients, raw OpenAPI codegen) | Add `idempotency_key` to polymorphic tools such as `get_products` now: calls that allocate tasks or commit/finalize state have no omission grace, and buyers cannot predict the execution arm. Add it to guaranteed side-effect-free synchronous reads (`list_creative_formats`, `list_accounts`, etc.) before the 3.2 cut; 3.1.x sellers MAY accept omission only on that narrower read path. | +| A buyer with hand-rolled MCP clients (curl, thin clients, raw OpenAPI codegen) | Add `idempotency_key` to every `get_products` call before the 4.0 cut: calls that allocate tasks or commit/finalize state have no omission grace, and buyers cannot predict the execution arm. Supplying keys on guaranteed pure reads (`list_creative_formats`, `list_accounts`, etc.) remains recommended for retry-stable responses but is not a universal 4.0 schema requirement. | | A seller on FastMCP/Pydantic, Zod `.strict()`, or OpenAPI codegen | Audit input models — they MUST accept envelope fields (`idempotency_key`, `context_id`, `context`, `governance_context`, `push_notification_config`) on every tool. Use `extra='allow'` (Pydantic), drop `.strict()` (Zod), or fix codegen config (OpenAPI). | | A seller emitting `create_media_buy` synchronous-success responses with embedded `status` | Nothing changes. Existing byte-stable replay already conforms; the rule was silent on this case before, now it's explicit. | | A buyer agent reading `status` from a mutation response | Add a `replayed: true` guard: if set, call the resource's read endpoint (`get_media_buys`, `list_accounts`, etc.) before any state-dependent action. Without the guard, you'll hit `NOT_CANCELLABLE` and similar state-machine bugs against replayed responses. | @@ -220,7 +220,7 @@ The cache holds read responses too from 3.1 onward — sellers MUST encrypt the | A seller currently emitting unknown error codes (platform-specific or pre-3.1 additions) | Add `error.recovery` to every error envelope before bumping advertised `adcp_version` to 3.1. Receivers default to `transient` when absent, which is safe but suboptimal — populating the field correctly is the spec-compliant path. | | An adopter with strict-validator test fixtures or codegen against `core/protocol-envelope.json` | Refresh fixtures and decoders. The schema drops `required: [status, payload]` (#2911) — strict validators that asserted "envelope MUST reject responses missing `payload`" will start accepting envelopes they used to reject. SDK consumers using OpenAPI / quicktype / Pydantic codegen against the envelope will see `status` and `payload` flip from required to optional in generated types; audit downstream decoders that depended on those being present at the schema level. A2A consumers carry `status` via `task.status.state`; MCP/REST consumers reading the envelope as a pure JSON Schema decoder are the affected set. The new normative serialization rules in the envelope's `notes` describe what wire shape to expect per transport. | -Additive over 3.0 — the entire bundle preserves wire compatibility for 3.0-conformant agents; the changes formalize behavior shipping SDKs already implement. +The 3.1 posture is additive over 3.0 and formalizes behavior shipping SDKs already implement. The 4.0 cut is the breaking boundary that makes the key schema-required on polymorphic `get_products`. ### Optimization goals — `kind: "vendor_metric"` (#4668, closes #4644) diff --git a/docs/reference/whats-new-in-3-1.mdx b/docs/reference/whats-new-in-3-1.mdx index 9568e05684..eccc5f1086 100644 --- a/docs/reference/whats-new-in-3-1.mdx +++ b/docs/reference/whats-new-in-3-1.mdx @@ -288,7 +288,7 @@ Normative tightenings landed as the spec settled through prerelease validation. - **`PROPOSAL_NOT_FOUND` error code** (#4043). Completes the proposal-lifecycle error catalog (alongside `PROPOSAL_EXPIRED` and `PROPOSAL_NOT_COMMITTED`). Sellers MUST return it when a referenced `proposal_id` isn't recognized — wrong tenant, evicted from cache, or never finalized. Recovery: correctable. - **Forward-compatible `error.code` decoding** (#4227). Receivers MUST treat `error.code` as an **open enum** — decode unknown codes without rejecting, classify recovery from `error.recovery`, default to `transient` when recovery is absent. Senders from 3.1 onward MUST populate `error.recovery` on every error. Unblocks additive-in-patch for error codes on future maintenance lines without breaking pinned-version receivers. -- **`idempotency_key` required on every AdCP task request** (#4399). Closes a longstanding gap where the spec said dedupe via idempotency_key but didn't require buyers to send one. In 3.1.x, sellers SHOULD reject omissions and MAY accept one only for a guaranteed side-effect-free synchronous read; requests that allocate a task or change state MUST reject before that effect. The omission grace closes entirely in 3.2. +- **`get_products` idempotency migration begins** (#4399). Closes a longstanding gap where the spec said dedupe via idempotency_key but did not account for a nominal read tool that can create tasks or finalize proposals. In 3.x, sellers MUST accept keyed calls, SHOULD reject omissions, and MAY accept one only for a guaranteed side-effect-free synchronous read; calls that allocate a task or change state MUST reject before that effect. AdCP 4.0 makes the key schema-required on every `get_products` call. - **MCP tool wrappers MUST tolerate envelope fields** (#4399). The protocol envelope (`status`, `context_id`, `context`, `task_id`, `timestamp`, `replayed`, `adcp_error`, `governance_context`, `idempotency_key`) on MCP requests now goes through the wrapper layer instead of being rejected as "unexpected fields." Closes a wrapper-layer bug where adopters had to omit envelope fields to call MCP successfully. - **MCP serialization normalization** (#2911). Drops `payload.required` from the protocol-envelope schema; adds the `context` field at envelope level; clarifies the flat-sibling MCP wire shape (envelope and body fields at the root, no nested `payload:` key). Adopters who'd implemented the de-facto flat shape are unaffected. - **Idempotency replay returns historical snapshot** (#4371). When a buyer retries a stateful create call (e.g., `create_media_buy`) within the replay window, the seller MUST return the **historical snapshot** of state-tracking fields (`status`, `confirmed_at`, etc.) — not the current state. Otherwise an at-most-once retry mutates the response from underneath the buyer. diff --git a/static/compliance/source/universal/read-tool-idempotency.yaml b/static/compliance/source/universal/read-tool-idempotency.yaml index d51ab0f714..574ead463d 100644 --- a/static/compliance/source/universal/read-tool-idempotency.yaml +++ b/static/compliance/source/universal/read-tool-idempotency.yaml @@ -3,7 +3,7 @@ version: "1.0.0" introduced_in: "3.1" title: "Read-tool idempotency_key tolerance" category: idempotency -summary: "Validates that read-only AdCP tasks accept the 3.1 every-request idempotency_key envelope without strict wrapper rejection." +summary: "Validates that read-only AdCP tasks accept an optional idempotency_key without strict wrapper rejection, while AdCP 4.0 get_products rejects an omitted key." track: core required_tools: [] # protocol-level baseline; read-tool probes gate themselves per step @@ -21,11 +21,11 @@ narrative: | mutating replay semantics, conflict detection, TTL declaration, and `replayed: true`. - The final phase intentionally omits `idempotency_key` on a read request. - During the 3.1 grace window, sellers SHOULD reject this omission but MAY - accept it for compatibility; therefore the probe is documented here as a - future 3.2 switch point rather than a hard failure. The 3.2 storyboard cut - should flip that probe to a required rejection. + The final phase intentionally omits `idempotency_key` on + `get_adcp_capabilities`. Sellers MUST accept this bootstrap request without a + key because it is how buyers discover the seller's idempotency declaration. + AdCP 4.0 separately requires the key on polymorphic `get_products`, covered by + the preceding negative vector. agent: interaction_model: "*" # applies to every agent regardless of protocol or specialism @@ -269,138 +269,110 @@ phases: value: "read_tool_idempotency--list_creatives_with_key" description: "Context correlation_id returned unchanged" - - id: omitted_key_grace_accept_path - title: "3.1 omitted-key grace window — accept path" + - id: get_products_missing_key_4_0 + title: "AdCP 4.0 get_products requires idempotency_key" narrative: | - Exercise the intentional omission vector on a read request. 3.1 sellers - SHOULD reject omitted `idempotency_key` values on reads but MAY continue - to accept them during the migration window. Passing this path records the - compatibility behavior without failing 3.1 acceptance; the peer reject - path below records the recommended behavior. - optional: true - branch_set: - id: omitted_key_grace_handled - semantics: any_of + `get_products` is polymorphic: a call may create a task or finalize a + proposal even when another call completes as a read. Its AdCP 4.0 schema + therefore requires `idempotency_key` before execution-arm selection. steps: - - id: get_capabilities_without_idempotency_key_3_1_accept - title: "Capabilities read without idempotency_key may still succeed in 3.1" + - id: get_products_without_idempotency_key_4_0 + title: "Missing get_products idempotency_key returns INVALID_REQUEST" narrative: | The request intentionally omits `idempotency_key` and sets `omit_idempotency_key: true` so future runners that auto-inject keys - for all tasks preserve the missing-key vector. Some 3.1 sellers may - still accept this request during migration. - task: get_adcp_capabilities - schema_ref: "protocol/get-adcp-capabilities-request.json" - response_schema_ref: "protocol/get-adcp-capabilities-response.json" - doc_ref: "/protocol/get_adcp_capabilities" + preserve the missing-key vector. The seller must reject before + creating a task, returning products, or finalizing state. + task: get_products + requires_tool: get_products + schema_ref: "media-buy/get-products-request.json" + response_schema_ref: "media-buy/get-products-response.json" + doc_ref: "/media-buy/task-reference/get_products" comply_scenario: read_tool_idempotency omit_idempotency_key: true + expect_error: true + negative_path: schema_invalid stateful: false - contributes: true expected: | - 3.1 grace-window accept behavior: a seller MAY accept this request - and return capabilities normally, but SHOULD prepare to reject omitted - `idempotency_key` on read tools. The 3.2 compliance cut should remove - this accept branch. + Reject before handler execution with: + - Error code: INVALID_REQUEST or VALIDATION_ERROR + - No task_id, products, or proposals payload sample_request: + buying_mode: "brief" + brief: "Show available advertising products for an outdoor lifestyle campaign." + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" context: - correlation_id: "read_tool_idempotency--capabilities_omitted_key_grace" + correlation_id: "read_tool_idempotency--get_products_missing_key_4_0" ext: adcp: storyboard: "read_tool_idempotency" - probe: "omitted_key_grace" + probe: "get_products_missing_key_4_0" validations: - - check: response_schema - description: "Response matches get-adcp-capabilities-response.json schema during the 3.1 omitted-key grace window" + - check: error_code + allowed_values: ["INVALID_REQUEST", "VALIDATION_ERROR"] + description: "Missing get_products idempotency_key is rejected" - check: field_present path: "context" description: "Response echoes back the context object" - check: field_value path: "context.correlation_id" - value: "read_tool_idempotency--capabilities_omitted_key_grace" + value: "read_tool_idempotency--get_products_missing_key_4_0" description: "Context correlation_id returned unchanged" + - check: field_absent + path: "task_id" + description: "Validation failure does not allocate an async task" + - check: field_absent + path: "products" + description: "Validation failure does not execute product discovery" + - check: field_absent + path: "proposals" + description: "Validation failure does not return or finalize proposals" - - id: omitted_key_grace_reject_path - title: "3.1 omitted-key grace window — reject path" + - id: capabilities_bootstrap_without_key + title: "Capabilities bootstrap accepts an omitted idempotency_key" narrative: | - Exercise the recommended 3.1 behavior for a read request that omits - `idempotency_key`: reject the request with `INVALID_REQUEST`. Passing - this path contributes the same branch-set flag as the accept path above, - so either behavior is conformant during the 3.1 migration window. - optional: true - branch_set: - id: omitted_key_grace_handled - semantics: any_of + Exercise the intentional omission vector on `get_adcp_capabilities`. + This bootstrap read is how the buyer learns the seller's idempotency + declaration, so sellers MUST accept it without a key. steps: - - id: get_capabilities_without_idempotency_key_3_1_reject - title: "Capabilities read without idempotency_key may reject in 3.1" + - id: get_capabilities_without_idempotency_key + title: "Capabilities read without idempotency_key succeeds" narrative: | The request intentionally omits `idempotency_key` and sets - `omit_idempotency_key: true`. In 3.1, sellers that already enforce - the every-request envelope should reject this with `INVALID_REQUEST`. - In 3.2 this becomes the only conformant branch. + `omit_idempotency_key: true` so future runners that auto-inject keys + preserve the bootstrap vector. The seller must return capabilities + normally rather than deadlock discovery behind the declaration being + discovered. task: get_adcp_capabilities schema_ref: "protocol/get-adcp-capabilities-request.json" response_schema_ref: "protocol/get-adcp-capabilities-response.json" doc_ref: "/protocol/get_adcp_capabilities" comply_scenario: read_tool_idempotency omit_idempotency_key: true - expect_error: true - negative_path: payload_well_formed stateful: false - contributes: true expected: | - 3.1 grace-window reject behavior: reject the read request with: - - Error code: INVALID_REQUEST - - Message indicating idempotency_key is required + Return capabilities normally without requiring `idempotency_key`. sample_request: context: - correlation_id: "read_tool_idempotency--capabilities_omitted_key_grace" + correlation_id: "read_tool_idempotency--capabilities_without_key" ext: adcp: storyboard: "read_tool_idempotency" - probe: "omitted_key_grace" + probe: "capabilities_without_key" validations: - - check: error_code - value: "INVALID_REQUEST" - description: "Missing idempotency_key rejected with INVALID_REQUEST" + - check: response_schema + description: "Response matches get-adcp-capabilities-response.json without an idempotency_key" - check: field_present path: "context" description: "Response echoes back the context object" - check: field_value path: "context.correlation_id" - value: "read_tool_idempotency--capabilities_omitted_key_grace" + value: "read_tool_idempotency--capabilities_without_key" description: "Context correlation_id returned unchanged" - - - id: omitted_key_grace_assertion - title: "3.1 omitted-key grace window — require handled outcome" - narrative: | - Synthetic assertion over the two optional omitted-key branches. In 3.1, - either success-path compatibility or recommended `INVALID_REQUEST` - rejection satisfies the migration window. A transport failure, unrelated - error, or schema-invalid success satisfies neither branch and is - non-conformant. The 3.2 storyboard cut should collapse this to the - rejection branch only. - - steps: - - id: assert_omitted_key_grace_handled - title: "Require omitted_key_grace_handled from either path" - narrative: | - At least one omitted-key branch must pass: the 3.1 compatibility - accept path or the 3.1 recommended reject path. - task: assert_contribution - comply_scenario: read_tool_idempotency - stateful: false - expected: | - At least one of omitted_key_grace_accept_path or - omitted_key_grace_reject_path contributed - `omitted_key_grace_handled`. - - validations: - - check: any_of - allowed_values: ["omitted_key_grace_handled"] - description: "Agent must either accept the omitted-key read during the 3.1 grace window or reject it with INVALID_REQUEST." From 823b65b8a9aa642ff270059ac452116ce61dcf3f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 10 Aug 2026 08:55:02 +0200 Subject: [PATCH 11/49] feat(media-buy): split product discovery tools for 3.2 --- .changeset/secure-get-products-idempotency.md | 4 +- docs.json | 4 + docs/accounts/overview.mdx | 2 + docs/building/by-layer/L1/security.mdx | 19 +- .../verification/compliance-catalog.mdx | 2 +- docs/building/verification/conformance.mdx | 2 +- docs/contributing/x-entity-annotation.md | 2 +- docs/media-buy/specification.mdx | 4 +- .../task-reference/finalize_proposals.mdx | 18 + .../media-buy/task-reference/get_products.mdx | 6 +- .../task-reference/list_products.mdx | 22 + .../task-reference/recommend_products.mdx | 22 + .../task-reference/refine_proposal.mdx | 25 + docs/protocol/get_adcp_capabilities.mdx | 6 +- docs/reference/release-notes.mdx | 24 +- docs/reference/whats-new-in-3-1.mdx | 2 +- scripts/build-schemas.cjs | 27 +- server/src/training-agent/idempotency.ts | 4 +- server/src/training-agent/mcp-task-store.ts | 66 +++ server/src/training-agent/source-schema.ts | 116 ++++ server/src/training-agent/task-handlers.ts | 544 ++++++++++++++---- server/src/training-agent/tenants/registry.ts | 2 + server/src/training-agent/tenants/router.ts | 151 ++++- .../tenants/tenant-smoke.test.ts | 191 +++++- .../src/training-agent/v6-sales-platform.ts | 44 ++ .../training-agent-webhooks.test.ts | 64 +++ server/tests/unit/idempotency.test.ts | 3 +- .../idempotent-task-postgres-recovery.test.ts | 97 ++++ .../product-discovery-schema-parity.test.ts | 107 ++++ .../unit/training-agent-idempotency.test.ts | 224 +++++++- server/tests/unit/training-agent.test.ts | 9 +- .../universal/read-tool-idempotency.yaml | 70 +-- .../source/core/account-authorization.json | 6 +- .../schemas/source/core/x-entity-types.json | 2 + static/schemas/source/index.json | 46 +- static/schemas/source/manifest.schema.json | 11 +- ...oposals-async-response-input-required.json | 7 + ...ze-proposals-async-response-submitted.json | 7 + ...lize-proposals-async-response-working.json | 7 + .../media-buy/finalize-proposals-request.json | 74 +++ .../finalize-proposals-response.json | 7 + .../media-buy/get-products-request.json | 8 +- .../media-buy/list-products-request.json | 95 +++ .../media-buy/list-products-response.json | 7 + .../source/media-buy/product-fields.json | 56 ++ .../source/media-buy/product-refinement.json | 86 +++ ...roducts-async-response-input-required.json | 7 + ...end-products-async-response-submitted.json | 7 + ...mmend-products-async-response-working.json | 7 + .../media-buy/recommend-products-request.json | 92 +++ .../recommend-products-response.json | 7 + ...roposal-async-response-input-required.json | 7 + ...ine-proposal-async-response-submitted.json | 7 + ...efine-proposal-async-response-working.json | 7 + .../media-buy/refine-proposal-request.json | 109 ++++ .../media-buy/refine-proposal-response.json | 7 + .../get-adcp-capabilities-response.json | 10 + tests/composed-schema-validation.test.cjs | 76 +++ tests/docs-nav-validation.test.cjs | 4 + 59 files changed, 2416 insertions(+), 233 deletions(-) create mode 100644 docs/media-buy/task-reference/finalize_proposals.mdx create mode 100644 docs/media-buy/task-reference/list_products.mdx create mode 100644 docs/media-buy/task-reference/recommend_products.mdx create mode 100644 docs/media-buy/task-reference/refine_proposal.mdx create mode 100644 server/src/training-agent/mcp-task-store.ts create mode 100644 server/src/training-agent/source-schema.ts create mode 100644 server/tests/unit/idempotent-task-postgres-recovery.test.ts create mode 100644 server/tests/unit/product-discovery-schema-parity.test.ts create mode 100644 static/schemas/source/media-buy/finalize-proposals-async-response-input-required.json create mode 100644 static/schemas/source/media-buy/finalize-proposals-async-response-submitted.json create mode 100644 static/schemas/source/media-buy/finalize-proposals-async-response-working.json create mode 100644 static/schemas/source/media-buy/finalize-proposals-request.json create mode 100644 static/schemas/source/media-buy/finalize-proposals-response.json create mode 100644 static/schemas/source/media-buy/list-products-request.json create mode 100644 static/schemas/source/media-buy/list-products-response.json create mode 100644 static/schemas/source/media-buy/product-fields.json create mode 100644 static/schemas/source/media-buy/product-refinement.json create mode 100644 static/schemas/source/media-buy/recommend-products-async-response-input-required.json create mode 100644 static/schemas/source/media-buy/recommend-products-async-response-submitted.json create mode 100644 static/schemas/source/media-buy/recommend-products-async-response-working.json create mode 100644 static/schemas/source/media-buy/recommend-products-request.json create mode 100644 static/schemas/source/media-buy/recommend-products-response.json create mode 100644 static/schemas/source/media-buy/refine-proposal-async-response-input-required.json create mode 100644 static/schemas/source/media-buy/refine-proposal-async-response-submitted.json create mode 100644 static/schemas/source/media-buy/refine-proposal-async-response-working.json create mode 100644 static/schemas/source/media-buy/refine-proposal-request.json create mode 100644 static/schemas/source/media-buy/refine-proposal-response.json diff --git a/.changeset/secure-get-products-idempotency.md b/.changeset/secure-get-products-idempotency.md index 81a8741f96..febd68695e 100644 --- a/.changeset/secure-get-products-idempotency.md +++ b/.changeset/secure-get-products-idempotency.md @@ -1,5 +1,5 @@ --- -"adcontextprotocol": major +"adcontextprotocol": minor --- -Require idempotency keys in AdCP 4.0 for polymorphic `get_products` requests so asynchronous discovery and proposal finalization are safe to retry. AdCP 3.x retains its bounded omission grace only for guaranteed side-effect-free synchronous reads. +Add the AdCP 3.2 `list_products`, `recommend_products`, `refine_proposal`, and `finalize_proposals` tools. The split tools give reads, recommendations, refinements, and commits explicit idempotency contracts while retaining `get_products` as a key-optional compatibility facade throughout 3.x. Keyed retries are equivalent across legacy and split names. diff --git a/docs.json b/docs.json index 59cd3919b2..af52ac81f0 100644 --- a/docs.json +++ b/docs.json @@ -293,6 +293,10 @@ "docs/media-buy/specification", "docs/media-buy/task-reference/index", "docs/media-buy/task-reference/get_products", + "docs/media-buy/task-reference/list_products", + "docs/media-buy/task-reference/recommend_products", + "docs/media-buy/task-reference/refine_proposal", + "docs/media-buy/task-reference/finalize_proposals", "docs/media-buy/task-reference/create_media_buy", "docs/media-buy/task-reference/sync_catalogs", "docs/media-buy/task-reference/get_media_buys", diff --git a/docs/accounts/overview.mdx b/docs/accounts/overview.mdx index 89b9a0f564..9dae22043f 100644 --- a/docs/accounts/overview.mdx +++ b/docs/accounts/overview.mdx @@ -144,6 +144,8 @@ Vendor agents that support scope introspection attach an `authorization` object | `scope_name` | Optional named scope identifier. Only `attestation_verifier` is standardized (media-buy-specific, binds to the **AAO Verified (Live)** qualifier); agent-defined names MUST use the `custom:` prefix so typos of the standard value fail schema validation rather than pass through. | | `read_only` | Convenience flag. When true, mutations return `READ_ONLY_SCOPE` regardless of whether the task is in `allowed_tasks`. Omission is equivalent to `false`. Callers MUST NOT infer read-only from `allowed_tasks` alone. | +**AdCP 3.2 product aliases.** `get_products` is the authorization name for the full product-discovery operation family, including every split tool the seller advertises. Sellers normalize split requests to canonical `get_products` fields before applying its `field_scopes`; alias-only grants and alias-specific field scopes are not defined during the 3.x compatibility window. A read-only grant permits `list_products`, rejects the three mutation-capable split tools, and permits legacy `get_products` only when the selected arm is guaranteed to remain a synchronous side-effect-free read. + ### Semantics of presence and absence - **Present**: the vendor agent asserts the shape reflects its enforcement for this caller on this account at this moment. Stale-by-seconds is fine; systematically-divergent is non-conformant. diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index 301e6c1107..10205aabef 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -397,22 +397,21 @@ A daypart with no declared semantics is ambiguous and MUST be rejected with `INV ### Idempotency -`idempotency_key` is **required on every state-mutating AdCP task request**. AdCP 4.0 classifies polymorphic `get_products` as state-mutating, so its request schema requires the key on every call even when a particular call completes synchronously as a read. Guaranteed pure-read tasks are not universally required to declare the key, but they MUST accept and apply the replay contract when a caller supplies one. Keys are scoped per `(authenticated agent, account)` — they have no meaning across agents on the same seller, across accounts under the same agent, or across sellers. Scoping by both dimensions prevents cross-account cache collisions when one agent (e.g. an agency) acts on multiple accounts: an identical-looking `create_media_buy` under account A and account B is two distinct buys, never one cached response replayed across the two. +`idempotency_key` is **required on every state-mutating AdCP task request**. Guaranteed pure-read tasks may leave it optional, but they MUST accept and apply the replay contract when a caller supplies one. Keys are scoped per `(authenticated agent, account)` — they have no meaning across agents on the same seller, across accounts under the same agent, or across sellers. -**`get_products` enforcement curve.** Sellers MUST reject any **mutating** request that omits `idempotency_key` with `INVALID_REQUEST` from 3.0 onward (unchanged). `get_products` historically looked read-only even though some valid arms mutate state, so its uniform schema requirement uses a major-version boundary: +**Product-discovery migration in 3.2.** AdCP 3.2 adds four explicit tools around the stable `get_products` operation family: -- **3.1–3.x** — sellers MUST accept `get_products` calls that carry `idempotency_key` and process per rules 2–9. Sellers SHOULD reject calls that omit it with `INVALID_REQUEST`; sellers MAY accept the omission **only when the request completes synchronously without creating a task or changing state**. A call that would return `Submitted` or commit/finalize state MUST reject an omitted key before performing that effect. -- **4.0.0** — the `get_products` request schema requires `idempotency_key` on every call. Sellers MUST reject an omission with `INVALID_REQUEST` before selecting an execution arm. +- `list_products` is a side-effect-free synchronous read; its key is optional. +- `recommend_products`, `refine_proposal`, and `finalize_proposals` require a key because they can allocate tasks or change proposal state. +- `get_products` remains valid throughout 3.x as a deprecated compatibility facade, with an optional key. Sellers MUST honor the replay contract whenever the caller supplies one. -This staged enforcement lets hand-rolled buyer integrations — built via curl, thin MCP clients, or OpenAPI codegen that doesn't include the field on `get_products` — migrate during 3.x before the 4.0 boundary. Buyer SDKs (`@adcp/client`, `adcp-py`) already send `idempotency_key` uniformly today, so SDK-using integrators are unaffected by the cut date. - -**Why uniform for `get_products`.** `get_products` is polymorphic: `buying_mode: 'brief'` / `'wholesale'` may complete synchronously (pure read), but the same tool MAY return a `Submitted` envelope when curation requires upstream queries or HITL, and `buying_mode: 'refine'` with `action: 'finalize'` is a commit that transitions a proposal to committed with an `expires_at` hold window (see [refinement guide § Finalize is exclusive](/docs/media-buy/product-discovery/refinement)). Buyers cannot predict at call time whether a given call will be a pure read, an async-task creation, or a commit — so the 4.0 wire contract requires `idempotency_key` on every `get_products` call uniformly. For calls that resolve as pure reads, the cache provides byte-stable replay-on-retry within the TTL, which is harmless and gives buyers a uniform retry-safe contract; for calls that resolve as async-task creation or commit, the cache provides the same at-most-once guarantees as on mutating tasks. The alternative — classifying per-call read-vs-mutating in the buyer's SDK — is not feasible when the same task name has both read and write modes. Decoding unknown `error.code` values returned by sellers (whether `INVALID_REQUEST` during the grace window or codes added in later minors) follows the [Forward-compatible decoding](/docs/building/by-layer/L3/error-handling#forward-compatible-decoding-normative) rule. +Aliases MUST normalize to the same canonical `get_products` payload before authorization, idempotency hashing, task recovery, persistence, and webhook emission. A retry may therefore switch between the legacy and split name without duplicating work; the same key with a different canonical payload returns `IDEMPOTENCY_CONFLICT`. This section applies only to AdCP task requests. OpenRTB bid streams have their own semantics (`BidRequest.id` is a transaction ID, not an idempotency key) and are out of scope. #### Normative seller behavior -1. **Schema validation runs first.** Sellers MUST validate the request against its schema (including presence and format of `idempotency_key` when declared) BEFORE consulting the idempotency cache. The sole 3.x `get_products` migration exception is an omitted key on a request that the seller can guarantee will complete as a side-effect-free synchronous read; this exception never permits `Submitted`, task allocation, or a state transition and is removed in 4.0. A malformed request returns `INVALID_REQUEST` without ever touching the cache — otherwise cache misses become a timing side channel that leaks whether schema validation accepted the key format. Validation errors are never cached (per rule 2). +1. **Schema validation runs first.** Sellers MUST validate the request against its schema (including presence and format of `idempotency_key` when declared) BEFORE consulting the idempotency cache. A malformed request returns `INVALID_REQUEST` without ever touching the cache — otherwise cache misses become a timing side channel that leaks whether schema validation accepted the key format. Validation errors are never cached (per rule 2). 2. **First call is canonical.** On **task success** (`status: completed` or `status: submitted` for async operations), the seller stores the inner response payload (not the protocol envelope) keyed by `(authenticated_agent, account_id, idempotency_key)` along with a hash of the canonical request payload. **The cache entry is immutable** — replays within the TTL MUST return the originally-cached payload (with `replayed: true`), and state-tracking fields in that payload MUST NOT be refreshed to reflect the resource's current state. This rule applies across both success branches: - **Async tasks** — the cached response is the `submitted` result containing `task_id`. Even if the async task subsequently completes, fails, or is canceled, a replay MUST return the originally-cached `submitted` response, NOT the current terminal state. The buyer uses the returned `task_id` to observe current state via `tasks/get` or webhook, exactly as it would have on the first call. @@ -494,7 +493,7 @@ AdCP SDK middleware ships JCS canonicalization so sellers don't roll their own. Buyer SDKs send envelope-level fields (`idempotency_key`, `context_id`, `context`, `governance_context`, `push_notification_config`) **uniformly across all AdCP tool calls** — buyers cannot know per-tool which envelope fields the seller's wrapper happens to declare. Servers MUST tolerate envelope-level fields that arrive in tool params but are not declared in the tool's parameter schema. Concretely: -- **`idempotency_key`** is required on state-mutating task schemas and on every AdCP 4.0 `get_products` request. Guaranteed pure-read schemas may leave it optional, but tool wrappers MUST accept it when supplied; the idempotency layer routes it per rules 2-9. Wrappers that reject the field with `unexpected_keyword_argument` (FastMCP/Pydantic strict signatures) are non-conformant. +- **`idempotency_key`** is required on state-mutating task schemas. Guaranteed pure-read schemas and the 3.x `get_products` compatibility facade may leave it optional, but tool wrappers MUST accept it when supplied; the idempotency layer routes it per rules 2-9. Wrappers that reject the field with `unexpected_keyword_argument` (FastMCP/Pydantic strict signatures) are non-conformant. - **`context_id`, `context`, `push_notification_config`, `governance_context`** MUST be accepted on every tool, including reads. Tools that don't consume a given field MUST ignore it; they MUST NOT reject the call because the envelope field is present. This is the server-side counterpart to the `additionalProperties: true` default that every published AdCP request schema declares. Configuring a server-side validator in a way that contradicts the schema's own `additionalProperties` declaration is a conformance violation. Common server-implementation traps: @@ -576,7 +575,7 @@ When in doubt, ask whether the buyer's intent is **"give me the same answer as b **Bootstrap carve-out — `get_adcp_capabilities`.** The discovery call itself is exempt from any schema-level key requirement. `get_adcp_capabilities` is how the buyer learns whether the seller declares `adcp.idempotency.replay_ttl_seconds`, so a fail-closed rule against the discovery call would deadlock the bootstrap. Buyers MAY omit `idempotency_key` on `get_adcp_capabilities`, and sellers MUST accept the call without it. Buyers that send `idempotency_key` on `get_adcp_capabilities` (e.g., SDKs that include the field uniformly) get the standard cache behavior — but the discovery call carries no state and replay is harmless. Every task request that carries a key remains subject to rules 1–9; the fail-closed obligation below applies to retry-sensitive operations once the capability fetch has completed. -**When the seller's capability declaration is missing.** A seller whose `get_adcp_capabilities` response omits `adcp.idempotency.replay_ttl_seconds` is non-compliant. After a successful capability fetch, client SDKs MUST fail closed before any state-mutating task and before every AdCP 4.0 `get_products` call against that seller — raise an error, don't assume a default — so the buyer learns about the non-compliance before a silent double-booking. A guaranteed pure-read task whose schema leaves the key optional MAY proceed without replay protection, but the client MUST NOT silently treat a retry as deduplicated. +**When the seller's capability declaration is missing.** A seller whose `get_adcp_capabilities` response omits `adcp.idempotency.replay_ttl_seconds` is non-compliant. After a successful capability fetch, client SDKs MUST fail closed before any state-mutating task — including `recommend_products`, `refine_proposal`, and `finalize_proposals` — so the buyer learns about the non-compliance before a silent duplicate. A guaranteed pure-read task whose schema leaves the key optional MAY proceed without replay protection, but the client MUST NOT silently treat a retry as deduplicated. **Decoding seller-emitted error codes.** Sellers MAY return error codes (`IDEMPOTENCY_CONFLICT`, `IDEMPOTENCY_EXPIRED`, `IDEMPOTENCY_IN_FLIGHT`, `INVALID_REQUEST`, or codes added in later minor versions) that buyers' pinned vocabulary may not recognize. Receivers MUST decode these per [Forward-compatible decoding (normative)](/docs/building/by-layer/L3/error-handling#forward-compatible-decoding-normative) — read `error.recovery` for the recovery classification, default to `transient` when `recovery` is absent, and never reject the response because the code value is unfamiliar. The retry semantics for `transient`-classified errors are bounded by [§ Retry Logic](/docs/building/by-layer/L3/error-handling#retry-logic) (`maxRetries` and exponential backoff with jitter) — buyers MUST NOT loop indefinitely on a `transient` default. diff --git a/docs/building/verification/compliance-catalog.mdx b/docs/building/verification/compliance-catalog.mdx index 1764ea62e4..875ab17343 100644 --- a/docs/building/verification/compliance-catalog.mdx +++ b/docs/building/verification/compliance-catalog.mdx @@ -31,7 +31,7 @@ Every agent runs every storyboard in `/compliance/{version}/universal/` regardle | `error-compliance-signals` | Error handling for signals protocol — nonexistent signal IDs, missing fields, VERSION_UNSUPPORTED, transport binding; discovery phases are gated on `get_signals`; activation phases run only for agents that claim activation support such as `signal-marketplace` | | `stale-response-advisory` | `STALE_RESPONSE` wire placement — advisory rides in `errors[]` on a populated success response with transport success preserved; stale-cache forcing steps gated on `comply_test_controller` with `force_upstream_unavailable` | | `idempotency` | `idempotency_key` scoping, replay semantics, `IDEMPOTENCY_CONFLICT`, `replayed: true`, declared TTL | -| `read-tool-idempotency` | Read-only task wrappers accept an optional `idempotency_key` without strict request-wrapper rejection; verifies the keyless capabilities bootstrap and 4.0 `get_products` missing-key rejection. | +| `read-tool-idempotency` | Read-only task wrappers and the 3.x `get_products` compatibility facade accept an optional `idempotency_key` without strict request-wrapper rejection; also verifies the keyless capabilities bootstrap. | | `canonical-format-validate-input` | Canonical-format `validate_input` result semantics — structural pass/fail across required slots and `unvalidatable_nondeterministic` for seeded product declarations. Gated on agents that advertise `validate_input`; the seeded-product branch also requires `comply_test_controller` seeding support. | | `security` | **Authentication baseline — unauth rejection, static-credential enforcement (Bearer API key or HTTP Basic), OAuth discovery + RFC 9728 audience binding.** See [Authentication](/docs/building/by-layer/L2/authentication). | | `oauth-setup` | Complete RFC 9728 protected-resource and RFC 8414 authorization-server metadata graph — resource/issuer consistency, every referenced authorization server, and passive endpoint reachability. Gated on `oauth.supported: true`; RFC 7591 registration transactions are out of scope. | diff --git a/docs/building/verification/conformance.mdx b/docs/building/verification/conformance.mdx index 0af08ef010..8e2b01dea0 100644 --- a/docs/building/verification/conformance.mdx +++ b/docs/building/verification/conformance.mdx @@ -104,7 +104,7 @@ Every agent MUST pass every storyboard below. | [`error_compliance_signals`](https://adcontextprotocol.org/compliance/latest/universal/error-compliance-signals) | Error handling for signals protocol — nonexistent signal IDs, missing fields, VERSION_UNSUPPORTED, transport binding; discovery phases are gated on `get_signals`; activation phases run only for agents that claim activation support such as `signal-marketplace` | | [`stale_response_advisory`](https://adcontextprotocol.org/compliance/latest/universal/stale-response-advisory) | `STALE_RESPONSE` wire placement — advisory rides in `errors[]` on a populated success response with transport success preserved; stale-cache forcing steps gated on `comply_test_controller` with `force_upstream_unavailable` | | [`idempotency`](https://adcontextprotocol.org/compliance/latest/universal/idempotency) | `idempotency_key` scoping, replay semantics, `IDEMPOTENCY_CONFLICT`, `replayed: true`, declared TTL | -| [`read_tool_idempotency`](https://adcontextprotocol.org/compliance/latest/universal/read-tool-idempotency) | Read-only task wrappers accept an optional `idempotency_key` without strict request-wrapper rejection; verifies the keyless capabilities bootstrap and 4.0 `get_products` missing-key rejection | +| [`read_tool_idempotency`](https://adcontextprotocol.org/compliance/latest/universal/read-tool-idempotency) | Read-only task wrappers and the 3.x `get_products` compatibility facade accept an optional `idempotency_key`; verifies the keyless capabilities bootstrap | | [`canonical_format_validate_input`](https://adcontextprotocol.org/compliance/latest/universal/canonical-format-validate-input) | Canonical-format `validate_input` result semantics: structural pass/fail across required slots and `unvalidatable_nondeterministic` for seeded product declarations. Gated on agents that advertise `validate_input`; the seeded-product branch also requires `comply_test_controller` seeding support. | | [`security_baseline`](https://adcontextprotocol.org/compliance/latest/universal/security) | Unauth rejection, static-credential enforcement (Bearer API key or HTTP Basic), OAuth discovery + RFC 9728 audience binding | | [`oauth_setup`](https://adcontextprotocol.org/compliance/latest/universal/oauth-setup) | Complete RFC 9728/RFC 8414 discovery graph, issuer/resource consistency, and passive endpoint reachability — skipped unless `oauth.supported: true` | diff --git a/docs/contributing/x-entity-annotation.md b/docs/contributing/x-entity-annotation.md index 0d803ec7d1..c26d485fd8 100644 --- a/docs/contributing/x-entity-annotation.md +++ b/docs/contributing/x-entity-annotation.md @@ -105,7 +105,7 @@ High-level groupings (see the registry for full descriptions). *Categories below |---|---| | Brand & rights | `advertiser_brand`, `rights_holder_brand`, `rights_grant` | | Account & party | `account`, `operator` | -| Media buy | `media_buy`, `package`, `product`, `product_pricing_option` | +| Media buy | `media_buy`, `package`, `product`, `proposal`, `product_pricing_option` | | Creative | `creative`, `creative_format` | | Data & targeting | `audience`, `audience_evidence`, `audience_evidence_snapshot`, `signal`, `signal_activation_id`, `demographic_interval_id`, `event_source` | | Lists & catalogs | `collection_list`, `property_list`, `catalog`, `property` | diff --git a/docs/media-buy/specification.mdx b/docs/media-buy/specification.mdx index 18cea122b7..6211cf2e68 100644 --- a/docs/media-buy/specification.mdx +++ b/docs/media-buy/specification.mdx @@ -548,13 +548,13 @@ Sales agents SHOULD target the following response times: ### Idempotency -Every state-mutating AdCP task request MUST carry an `idempotency_key`. In AdCP 4.0 that includes every `get_products` call because the tool is polymorphic. Sales agents MUST apply the replay contract: +Every state-mutating AdCP task request MUST carry an `idempotency_key`. In AdCP 3.2, product discovery uses explicit contracts: `recommend_products`, `refine_proposal`, and `finalize_proposals` require a key; `list_products` and the legacy 3.x `get_products` facade leave it optional. Sales agents MUST apply the replay contract whenever a key is supplied: - The same key and equivalent canonical payload within the replay window MUST return the original response without re-executing the request. - The same key with a different canonical payload MUST return `IDEMPOTENCY_CONFLICT`. - Keys are scoped to the authenticated caller and account so retries cannot cross tenant boundaries. -The 3.x `get_products` migration grace applies only when a seller can guarantee that an omitted-key request will complete as a side-effect-free synchronous read. It never permits a `Submitted` response, task allocation, proposal finalization, or another state transition, and it is removed in AdCP 4.0. `get_products` is polymorphic, so buyers MUST include the key on every call rather than attempting to predict which execution arm the seller will choose. See [Request safety — idempotency](/docs/building/by-layer/L1/security#idempotency) for the normative cache, replay, concurrency, and 4.0 enforcement rules. +During 3.x, split and legacy calls are aliases in one logical operation family. Sellers normalize them before authorization, idempotency, task recovery, persistence, and webhook emission, so a retry may safely switch names. New buyers SHOULD use the split tools; sellers MUST retain `get_products` through 3.x. See [Request safety — idempotency](/docs/building/by-layer/L1/security#idempotency) for the normative cache, replay, and concurrency rules. ### Human-in-the-Loop diff --git a/docs/media-buy/task-reference/finalize_proposals.mdx b/docs/media-buy/task-reference/finalize_proposals.mdx new file mode 100644 index 0000000000..a2d070c9e3 --- /dev/null +++ b/docs/media-buy/task-reference/finalize_proposals.mdx @@ -0,0 +1,18 @@ +--- +title: finalize_proposals +description: "Atomically commit one or more draft proposals." +testable: true +--- + +`finalize_proposals` is the explicit AdCP 3.2 proposal commit boundary. `idempotency_key` is required, and `proposal_ids` must be non-empty and unique. + +**Request schema:** [`/schemas/latest/media-buy/finalize-proposals-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/finalize-proposals-request.json) + +```json +{ + "idempotency_key": "550e8400-e29b-41d4-a716-446655441003", + "proposal_ids": ["proposal_123", "proposal_456"] +} +``` + +Multi-proposal finalization retains the legacy atomic pre-commit validation rule: if any proposal is invalid, the seller rejects the request without committing any named proposal. During 3.x, it shares replay identity with the equivalent exclusive legacy finalize array. diff --git a/docs/media-buy/task-reference/get_products.mdx b/docs/media-buy/task-reference/get_products.mdx index a1cabe9f41..5d8dcff29b 100644 --- a/docs/media-buy/task-reference/get_products.mdx +++ b/docs/media-buy/task-reference/get_products.mdx @@ -7,6 +7,10 @@ testable: true Discover available advertising products based on campaign requirements using natural language briefs or structured filters. + +`get_products` is deprecated for new integrations in AdCP 3.2, but remains fully supported throughout 3.x. Use [`list_products`](/docs/media-buy/task-reference/list_products), [`recommend_products`](/docs/media-buy/task-reference/recommend_products), [`refine_proposal`](/docs/media-buy/task-reference/refine_proposal), or [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals). Its `idempotency_key` remains optional; when supplied, retries share the same replay identity as the equivalent split call. + + **Why this shape.** Targeting, pricing, and curation are folded into one round-trip — the brief drives discovery, the publisher curates against it, and `pricing_options` carry firm prices the buyer commits against via `pricing_option_id`. We rejected a separate `get_price_quote` step between products and buy creation: it splits one expert decision into two underspecified ones and breaks the brief→curation contract. Iteration is `buying_mode: "refine"` with a typed change array — not a new task. → [Design principle: the brief drives discovery](/docs/protocol/design-principles#3-the-brief-drives-discovery-targeting-is-an-input-not-a-step). @@ -147,7 +151,7 @@ asyncio.run(discover_with_filters()) | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `idempotency_key` | string | Yes | Unique key for this logical request (16–255 characters; letters, digits, `_`, `.`, `:`, and `-`). Reuse the same key only when retrying the exact initial request, including after a timeout when the original request may have returned `Submitted`. Poll the resulting task through `get_task_status` or MCP task APIs; each new polling request carries its own fresh key. Use a new key whenever any request parameter changes. | +| `idempotency_key` | string | No | Optional key for retry-safe use of the 3.x compatibility facade (16–255 characters; letters, digits, `_`, `.`, `:`, and `-`). When supplied, reuse the same key only when retrying the exact initial request, including through the equivalent 3.2 split task. Poll the resulting task through `get_task_status` or MCP task APIs; each new polling request carries its own fresh key. Use a new key whenever any request parameter changes. | | `buying_mode` | string | Yes | `"brief"`, `"wholesale"`, or `"refine"`. `"brief"`: publisher curates products from the brief. `"wholesale"`: raw product feed access for buyer-directed targeting, `brief` must not be provided. `"refine"`: iterate on products and proposals from a previous response using the `refine` array of change requests. v3 clients MUST include `buying_mode`. Sellers receiving requests from pre-v3 clients without `buying_mode` SHOULD default to `"brief"`. **Timing semantics:** `"wholesale"` is a wholesale product feed read — sellers SHOULD return a synchronous response and MUST NOT route a `"wholesale"` request through the async/Submitted arm. Partial completion is signalled via [`incomplete[]`](#incomplete-array), not a task handoff. `"brief"` and `"refine"` MAY complete synchronously OR MAY return a `Submitted` envelope when curation requires upstream-system queries or HITL review the seller cannot complete inside `time_budget`. Buyers needing predictable fast wholesale product feed access MUST use `"wholesale"`. | | `brief` | string | Conditional | Natural language description of campaign requirements. Required when `buying_mode` is `"brief"`. Must not be provided when `buying_mode` is `"wholesale"` or `"refine"`. | | `refine` | [Refine[]](#refine-array) | Conditional | Array of change requests for iterating on products and proposals. Required when `buying_mode` is `"refine"`. Must not be provided when `buying_mode` is `"brief"` or `"wholesale"`. See [Refine array](#refine-array) below. | diff --git a/docs/media-buy/task-reference/list_products.mdx b/docs/media-buy/task-reference/list_products.mdx new file mode 100644 index 0000000000..4cc8735334 --- /dev/null +++ b/docs/media-buy/task-reference/list_products.mdx @@ -0,0 +1,22 @@ +--- +title: list_products +description: "List the seller's synchronous wholesale product feed." +testable: true +--- + +`list_products` is the AdCP 3.2 side-effect-free replacement for `get_products` with `buying_mode: "wholesale"`. It never allocates an asynchronous task. `idempotency_key` is optional; when supplied, the seller applies the normal replay contract. + +**Request schema:** [`/schemas/latest/media-buy/list-products-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/list-products-request.json) + +```json +{ + "idempotency_key": "550e8400-e29b-41d4-a716-446655441000", + "account": { + "brand": { "domain": "acmeoutdoor.example" }, + "operator": "pinnacle-agency.example" + }, + "pagination": { "max_results": 25 } +} +``` + +During 3.x, this request is canonically equivalent to the same keyed `get_products` request with `buying_mode: "wholesale"`. Retrying under either name returns the original result. diff --git a/docs/media-buy/task-reference/recommend_products.mdx b/docs/media-buy/task-reference/recommend_products.mdx new file mode 100644 index 0000000000..23389a3260 --- /dev/null +++ b/docs/media-buy/task-reference/recommend_products.mdx @@ -0,0 +1,22 @@ +--- +title: recommend_products +description: "Request curated products from a campaign brief." +testable: true +--- + +`recommend_products` is the AdCP 3.2 replacement for `get_products` with `buying_mode: "brief"`. It may allocate an asynchronous task, so `idempotency_key` is required. + +**Request schema:** [`/schemas/latest/media-buy/recommend-products-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/recommend-products-request.json) + +```json +{ + "idempotency_key": "550e8400-e29b-41d4-a716-446655441001", + "brief": "Reach outdoor enthusiasts with premium video inventory.", + "account": { + "brand": { "domain": "acmeoutdoor.example" }, + "operator": "pinnacle-agency.example" + } +} +``` + +During 3.x, keyed retries are equivalent across `recommend_products` and the matching legacy `get_products` call. diff --git a/docs/media-buy/task-reference/refine_proposal.mdx b/docs/media-buy/task-reference/refine_proposal.mdx new file mode 100644 index 0000000000..a26ebba115 --- /dev/null +++ b/docs/media-buy/task-reference/refine_proposal.mdx @@ -0,0 +1,25 @@ +--- +title: refine_proposal +description: "Refine one draft proposal without committing it." +testable: true +--- + +`refine_proposal` preserves the established `refine[]` grammar while making the non-commit boundary explicit. `idempotency_key` is required. At least one entry must be proposal-scoped, and every proposal-scoped entry must name the same `proposal_id`. `action: "finalize"` is invalid; use `finalize_proposals`. + +**Request schema:** [`/schemas/latest/media-buy/refine-proposal-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/refine-proposal-request.json) + +```json +{ + "idempotency_key": "550e8400-e29b-41d4-a716-446655441002", + "refine": [ + { + "scope": "proposal", + "proposal_id": "proposal_123", + "action": "include", + "ask": "Shift more budget toward video." + } + ] +} +``` + +Request- and product-scoped changes may accompany the proposal change. The seller normalizes the request to the equivalent non-finalizing `get_products` refine call. diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index acc44f919d..ef28eb1067 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -90,7 +90,7 @@ Core AdCP protocol information: #### idempotency -Declares whether this seller honors `idempotency_key` replay protection. AdCP requires keys on mutating requests and requires sellers to accept them on pure reads when supplied. AdCP 4.0 classifies polymorphic `get_products` as mutating and requires the key on every call; guaranteed pure-read request schemas may continue to leave it optional. See [security.mdx § Idempotency](/docs/building/by-layer/L1/security#idempotency). Mirrors the `request_signing.supported` pattern — a single positive declaration, decoupled from the window detail. Clients MUST NOT assume a default; a seller without this block is non-compliant and should be treated as unsafe for retry-sensitive operations across all call modes. +Declares whether this seller honors `idempotency_key` replay protection. AdCP requires keys on mutating requests and requires sellers to accept them on pure reads when supplied. In the AdCP 3.2 product split, `recommend_products`, `refine_proposal`, and `finalize_proposals` require keys; `list_products` and the legacy 3.x `get_products` facade leave them optional. See [security.mdx § Idempotency](/docs/building/by-layer/L1/security#idempotency). Clients MUST NOT assume a default; a seller without this block is non-compliant and should be treated as unsafe for retry-sensitive operations. | Field | Type | Description | |-------|------|-------------| @@ -362,6 +362,10 @@ See [Accounts and Agents](/docs/building/by-layer/L2/accounts-and-agents#what-se Media-buy protocol capabilities. Only present if `media_buy` is in `supported_protocols`. Sellers declaring `media_buy` should also include `account` (with `supported_billing`) and `media_buy.portfolio` — buyers need both to establish billing and understand inventory coverage. Compliance testing validates their presence. +#### product_discovery_tools + +AdCP 3.2 sellers use `product_discovery_tools` to advertise any supported subset of `get_products`, `list_products`, `recommend_products`, `refine_proposal`, and `finalize_proposals`. When absent, buyers use the legacy `get_products` facade and inspect `buying_modes`. Split and legacy names share one authorization and idempotency operation family throughout 3.x. + :::note 3.0 breaking changes The following fields have been removed from the capabilities response: - `media_buy.reporting` — Reporting is implied by `media_buy`. Use product-level `reporting_capabilities` instead. diff --git a/docs/reference/release-notes.mdx b/docs/reference/release-notes.mdx index f2730256a8..c8cfc2ee3c 100644 --- a/docs/reference/release-notes.mdx +++ b/docs/reference/release-notes.mdx @@ -13,6 +13,17 @@ Authoritative version-by-version release record for AdCP, with cumulative change **Status:** In development — minor release targeting the 3.2.0 milestone. Stable wire shapes remain backward compatible; explicitly experimental surfaces may carry noticed changes under the [experimental-status contract](/docs/reference/experimental-status). +### Product discovery split with cross-alias idempotency (#6115) + +AdCP 3.2 adds `list_products`, `recommend_products`, `refine_proposal`, and `finalize_proposals`. These are additive aliases for the four jobs previously combined in `get_products`, which remains schema-valid and functional throughout 3.x. + +- `list_products` is a synchronous read and accepts an optional `idempotency_key`. +- `recommend_products`, `refine_proposal`, and `finalize_proposals` require a key. +- Sellers normalize split and legacy calls to one `get_products` operation identity before authorization, idempotency hashing, task recovery, persistence, and webhook emission. Retrying under the equivalent alias replays the original result rather than duplicating work. +- `media_buy.product_discovery_tools` advertises the split surface. Its absence means callers use `get_products` and `buying_modes`. + +New integrations SHOULD use the split tools. Existing integrations do not need an immediate migration: `get_products` keeps its optional-key 3.x contract, and sellers MUST honor replay protection whenever a key is supplied. + ### Inline asset-access credentials deprecated (#5698) `service_account.credentials` in secured content-artifact access is deprecated. New producers must omit it. For one-off delivery, use an asset-scoped, short-lived signed URL; established integrations may use the fetching component's pre-authorized workload identity, and origins that require an Authorization header may use a short-lived, asset-scoped bearer token. @@ -76,7 +87,7 @@ Additive over 3.0 — every existing brand.json publisher continues to validate - **Signals and wholesale feeds:** enriched signal definitions, `SignalRef`, wholesale signal enumeration, product-scoped signal targeting, conditional fetch, `cache_scope`, and feed webhooks. - **Creative formats and generation:** canonical `format_kind`, publisher catalogs, hosted audio/video `duration_ms_range`, published-post references, `list_transformers`, fan-out, evaluator ranking over creative features, spend controls, and per-output pricing receipts. - **Media-buy operations:** dependency impairments, buyer-visible webhook activity, action discovery, proposal cleanup, currency-scoped discovery, sponsored/social placement fields, and SI availability status. -- **Measurement, billing, and runtime hardening:** vendor-attested goals, reach-window semantics, delivery/usage finality, idempotency on every task, open error-code decoding, auth tightening, webhook operation-id echo, and flat MCP envelope tolerance. +- **Measurement, billing, and runtime hardening:** vendor-attested goals, reach-window semantics, delivery/usage finality, idempotency on state-changing and explicitly keyed task calls, open error-code decoding, auth tightening, webhook operation-id echo, and flat MCP envelope tolerance. - **SDK and compliance readiness:** named codegen schemas, async-response refs, open-payload markers, capability-gated storyboards, packaged compliance closure, and release-artifact drift checks. @@ -182,12 +193,9 @@ Existing exact durations and closed ranges remain valid. PR [#5323](https://gith ### Wire conformance — idempotency & envelope tolerance (#4399 / #4399b, #2911, #4227, #4371, #4418, #4107, #4196, #4043) -A bundle of normative clarifications that codify deployed behavior and close ambiguities that surfaced from real adopter integrations. None require code changes for SDK-using integrators; hand-rolled MCP clients should add `idempotency_key` to `get_products` before the 4.0 cut. - -**`get_products` requires `idempotency_key` in AdCP 4.0 (#4399b).** The 3.0 contract framed the field as mutating-only, but `get_products` is polymorphic (`brief` may return `Submitted`; `refine`+`finalize` is a commit) and buyers can't classify every call at request-construction time. Migration is **staged**: +A bundle of normative clarifications that codify deployed behavior and close ambiguities that surfaced from real adopter integrations. -- **3.1–3.x** — sellers MUST accept `get_products` calls that carry `idempotency_key`; SHOULD reject calls that omit it (MAY accept the omission only when that call is guaranteed to complete as a side-effect-free synchronous read). Calls that return `Submitted` or commit/finalize state are never covered by the omission grace. -- **4.0.0** — the `get_products` request schema requires the key on every call. Guaranteed pure-read tasks may continue to leave it optional. +**`get_products` accepts keyed replay (#4399b).** Sellers MUST accept `idempotency_key` on product discovery and apply the normal replay contract. AdCP 3.2 subsequently adds explicit read, recommendation, refinement, and finalization tools while preserving the optional-key legacy facade throughout 3.x. The cache holds read responses too from 3.1 onward — sellers MUST encrypt the cache tier at rest with the same controls applied to the underlying resource store. Operators with read-heavy buyer mixes SHOULD raise the rule-8 insert ceiling (sized originally against write-heavy traffic). See [security.mdx § Idempotency](/docs/building/by-layer/L1/security#idempotency) for the full contract. @@ -212,7 +220,7 @@ The cache holds read responses too from 3.1 onward — sellers MUST encrypt the | If you are… | What you need to do | |---|---| | A buyer using `@adcp/client` or `adcp-py` | Nothing. Both SDKs already send `idempotency_key` uniformly on every call. | -| A buyer with hand-rolled MCP clients (curl, thin clients, raw OpenAPI codegen) | Add `idempotency_key` to every `get_products` call before the 4.0 cut: calls that allocate tasks or commit/finalize state have no omission grace, and buyers cannot predict the execution arm. Supplying keys on guaranteed pure reads (`list_creative_formats`, `list_accounts`, etc.) remains recommended for retry-stable responses but is not a universal 4.0 schema requirement. | +| A buyer with hand-rolled MCP clients (curl, thin clients, raw OpenAPI codegen) | Prefer the AdCP 3.2 split product-discovery tools and include `idempotency_key` on recommendations, refinements, and finalization. Existing `get_products` calls remain valid throughout 3.x. | | A seller on FastMCP/Pydantic, Zod `.strict()`, or OpenAPI codegen | Audit input models — they MUST accept envelope fields (`idempotency_key`, `context_id`, `context`, `governance_context`, `push_notification_config`) on every tool. Use `extra='allow'` (Pydantic), drop `.strict()` (Zod), or fix codegen config (OpenAPI). | | A seller emitting `create_media_buy` synchronous-success responses with embedded `status` | Nothing changes. Existing byte-stable replay already conforms; the rule was silent on this case before, now it's explicit. | | A buyer agent reading `status` from a mutation response | Add a `replayed: true` guard: if set, call the resource's read endpoint (`get_media_buys`, `list_accounts`, etc.) before any state-dependent action. Without the guard, you'll hit `NOT_CANCELLABLE` and similar state-machine bugs against replayed responses. | @@ -220,7 +228,7 @@ The cache holds read responses too from 3.1 onward — sellers MUST encrypt the | A seller currently emitting unknown error codes (platform-specific or pre-3.1 additions) | Add `error.recovery` to every error envelope before bumping advertised `adcp_version` to 3.1. Receivers default to `transient` when absent, which is safe but suboptimal — populating the field correctly is the spec-compliant path. | | An adopter with strict-validator test fixtures or codegen against `core/protocol-envelope.json` | Refresh fixtures and decoders. The schema drops `required: [status, payload]` (#2911) — strict validators that asserted "envelope MUST reject responses missing `payload`" will start accepting envelopes they used to reject. SDK consumers using OpenAPI / quicktype / Pydantic codegen against the envelope will see `status` and `payload` flip from required to optional in generated types; audit downstream decoders that depended on those being present at the schema level. A2A consumers carry `status` via `task.status.state`; MCP/REST consumers reading the envelope as a pure JSON Schema decoder are the affected set. The new normative serialization rules in the envelope's `notes` describe what wire shape to expect per transport. | -The 3.1 posture is additive over 3.0 and formalizes behavior shipping SDKs already implement. The 4.0 cut is the breaking boundary that makes the key schema-required on polymorphic `get_products`. +The 3.1 posture is additive over 3.0 and formalizes behavior shipping SDKs already implement. AdCP 3.2 resolves the overloaded product-discovery surface additively. ### Optimization goals — `kind: "vendor_metric"` (#4668, closes #4644) diff --git a/docs/reference/whats-new-in-3-1.mdx b/docs/reference/whats-new-in-3-1.mdx index eccc5f1086..5ad9df2636 100644 --- a/docs/reference/whats-new-in-3-1.mdx +++ b/docs/reference/whats-new-in-3-1.mdx @@ -288,7 +288,7 @@ Normative tightenings landed as the spec settled through prerelease validation. - **`PROPOSAL_NOT_FOUND` error code** (#4043). Completes the proposal-lifecycle error catalog (alongside `PROPOSAL_EXPIRED` and `PROPOSAL_NOT_COMMITTED`). Sellers MUST return it when a referenced `proposal_id` isn't recognized — wrong tenant, evicted from cache, or never finalized. Recovery: correctable. - **Forward-compatible `error.code` decoding** (#4227). Receivers MUST treat `error.code` as an **open enum** — decode unknown codes without rejecting, classify recovery from `error.recovery`, default to `transient` when recovery is absent. Senders from 3.1 onward MUST populate `error.recovery` on every error. Unblocks additive-in-patch for error codes on future maintenance lines without breaking pinned-version receivers. -- **`get_products` idempotency migration begins** (#4399). Closes a longstanding gap where the spec said dedupe via idempotency_key but did not account for a nominal read tool that can create tasks or finalize proposals. In 3.x, sellers MUST accept keyed calls, SHOULD reject omissions, and MAY accept one only for a guaranteed side-effect-free synchronous read; calls that allocate a task or change state MUST reject before that effect. AdCP 4.0 makes the key schema-required on every `get_products` call. +- **`get_products` keyed replay support** (#4399). Closes a longstanding gap where the spec said dedupe via idempotency_key but did not account for a nominal read tool that can create tasks or finalize proposals. Sellers MUST accept keyed calls and apply the replay contract. AdCP 3.2 follows with additive split tools whose read and mutation contracts are explicit; legacy `get_products` remains key-optional throughout 3.x. - **MCP tool wrappers MUST tolerate envelope fields** (#4399). The protocol envelope (`status`, `context_id`, `context`, `task_id`, `timestamp`, `replayed`, `adcp_error`, `governance_context`, `idempotency_key`) on MCP requests now goes through the wrapper layer instead of being rejected as "unexpected fields." Closes a wrapper-layer bug where adopters had to omit envelope fields to call MCP successfully. - **MCP serialization normalization** (#2911). Drops `payload.required` from the protocol-envelope schema; adds the `context` field at envelope level; clarifies the flat-sibling MCP wire shape (envelope and body fields at the root, no nested `payload:` key). Adopters who'd implemented the de-facto flat shape are unaffected. - **Idempotency replay returns historical snapshot** (#4371). When a buyer retries a stateful create call (e.g., `create_media_buy`) within the replay window, the seller MUST return the **historical snapshot** of state-tracking fields (`status`, `confirmed_at`, etc.) — not the current state. Otherwise an at-most-once retry mutates the response from underneath the buyer. diff --git a/scripts/build-schemas.cjs b/scripts/build-schemas.cjs index 18cb78ff0a..ff9969e168 100644 --- a/scripts/build-schemas.cjs +++ b/scripts/build-schemas.cjs @@ -369,6 +369,13 @@ function hasNaturallyIdempotentMarker(schema) { return /naturally idempotent/i.test(haystack); } +function isDeprecatedGetProductsCompatibilityFacade(schema) { + return schema.$id === '/schemas/media-buy/get-products-request.json' + && schema['x-operation-family'] === 'get_products' + && schema['x-deprecated-in'] === '3.2.0' + && schema['x-idempotency-key-required'] === false; +} + // Classify a request schema as mutating or non-mutating using the same rules // the lint enforces. Returns true if the operation mutates state. // @@ -408,6 +415,11 @@ function lintMutatingRequestsRequireIdempotencyKey(sourceDir) { const required = Array.isArray(schema.required) ? schema.required : []; if (required.includes('idempotency_key')) continue; if (hasNaturallyIdempotentMarker(schema)) continue; + // A stable 3.x compatibility facade may remain polymorphic while newer, + // narrow replacement tools carry the required-key contract. This marker + // is deliberately explicit so a newly added mutator cannot become + // key-optional by accident. + if (isDeprecatedGetProductsCompatibilityFacade(schema)) continue; violations.push(path.relative(sourceDir, p)); } } @@ -1022,6 +1034,7 @@ function discoverTools(sourceDir) { const toolBase = f.name.replace(/-request\.json$/, ''); const toolName = toolBase.replace(/-/g, '_'); const requestPath = path.join(protoDir, f.name); + const requestSchema = JSON.parse(fs.readFileSync(requestPath, 'utf8')); const responseName = `${toolBase}-response.json`; const responsePath = path.join(protoDir, responseName); if (!fs.existsSync(responsePath)) { @@ -1042,6 +1055,14 @@ function discoverTools(sourceDir) { name: toolName, protocol, mutating, + operation_family: requestSchema['x-operation-family'] || toolName, + idempotency_requirement: Array.isArray(requestSchema.required) && requestSchema.required.includes('idempotency_key') + ? 'required' + : requestSchema.properties?.idempotency_key + ? 'optional' + : 'none', + ...(requestSchema['x-added-in'] ? { added_in: requestSchema['x-added-in'] } : {}), + ...(requestSchema['x-deprecated-in'] ? { deprecated_in: requestSchema['x-deprecated-in'] } : {}), request_schema: `${protocol}/${f.name}`, response_schema: `${protocol}/${responseName}`, async_response_schemas: asyncVariants @@ -1113,10 +1134,14 @@ function buildManifest(sourceDir, urlVersion, semverVersion, repoRoot) { toolsObj[t.name] = { protocol: t.protocol, mutating: t.mutating, + operation_family: t.operation_family, + idempotency_requirement: t.idempotency_requirement, request_schema: t.request_schema, response_schema: t.response_schema, async_response_schemas: t.async_response_schemas, - ...(t.specialisms ? { specialisms: t.specialisms } : {}) + ...(t.specialisms ? { specialisms: t.specialisms } : {}), + ...(t.added_in ? { added_in: t.added_in } : {}), + ...(t.deprecated_in ? { deprecated_in: t.deprecated_in } : {}) }; } diff --git a/server/src/training-agent/idempotency.ts b/server/src/training-agent/idempotency.ts index 9dddd90d3c..ed4e5f5f2d 100644 --- a/server/src/training-agent/idempotency.ts +++ b/server/src/training-agent/idempotency.ts @@ -68,9 +68,11 @@ export const MUTATING_TOOLS: ReadonlySet = new Set([ 'creative_approval', 'delete_collection_list', 'delete_property_list', - 'get_products', + 'finalize_proposals', 'log_event', 'provide_performance_feedback', + 'recommend_products', + 'refine_proposal', 'report_plan_outcome', 'report_usage', 'si_initiate_session', diff --git a/server/src/training-agent/mcp-task-store.ts b/server/src/training-agent/mcp-task-store.ts new file mode 100644 index 0000000000..b6584e7271 --- /dev/null +++ b/server/src/training-agent/mcp-task-store.ts @@ -0,0 +1,66 @@ +import { InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental/tasks'; +import type { + CreateTaskOptions, + TaskStore, +} from '@modelcontextprotocol/sdk/experimental/tasks/interfaces.js'; +import type { Request, RequestId, Result, Task } from '@modelcontextprotocol/sdk/types.js'; +import { PostgresTaskStore } from '@adcp/sdk'; +import { getPool, isDatabaseInitialized } from '../db/client.js'; + +export type TrainingTaskStore = InMemoryTaskStore | PostgresTaskStore; + +let taskStore: TrainingTaskStore | null = null; + +/** Resolve the backing store only when the first task operation runs. Route + * construction precedes database initialization in production. */ +export function getTrainingTaskStore(): TrainingTaskStore { + if (!taskStore) { + taskStore = isDatabaseInitialized() + ? new PostgresTaskStore(getPool()) + : new InMemoryTaskStore(); + } + return taskStore; +} + +/** One MCP TaskStore shared by the SDK tenant facade and the native 3.2 + * product dispatcher. The proxy preserves lazy database selection. */ +export const sharedTrainingTaskStore: TaskStore = { + createTask( + options: CreateTaskOptions, + requestId: RequestId, + request: Request, + sessionId?: string, + ): Promise { + return getTrainingTaskStore().createTask(options, requestId, request, sessionId); + }, + getTask(taskId: string, sessionId?: string): Promise { + return getTrainingTaskStore().getTask(taskId, sessionId); + }, + storeTaskResult( + taskId: string, + status: 'completed' | 'failed', + result: Result, + sessionId?: string, + ): Promise { + return getTrainingTaskStore().storeTaskResult(taskId, status, result, sessionId); + }, + getTaskResult(taskId: string, sessionId?: string): Promise { + return getTrainingTaskStore().getTaskResult(taskId, sessionId); + }, + updateTaskStatus( + taskId: string, + status: Task['status'], + statusMessage?: string, + sessionId?: string, + ): Promise { + return getTrainingTaskStore().updateTaskStatus(taskId, status, statusMessage, sessionId); + }, + listTasks(cursor?: string, sessionId?: string): Promise<{ tasks: Task[]; nextCursor?: string }> { + return getTrainingTaskStore().listTasks(cursor, sessionId); + }, +}; + +export function resetTrainingTaskStore(): void { + taskStore?.cleanup(); + taskStore = null; +} diff --git a/server/src/training-agent/source-schema.ts b/server/src/training-agent/source-schema.ts new file mode 100644 index 0000000000..adb91f23ff --- /dev/null +++ b/server/src/training-agent/source-schema.ts @@ -0,0 +1,116 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +type JsonSchema = Record; + +const schemaRoot = join(process.cwd(), 'static/schemas/source'); +const parsedSchemas = new Map(); +const definitionAnnotationKeys = new Set([ + '$comment', + 'default', + 'deprecated', + 'description', + 'discriminator', + 'example', + 'examples', + 'readOnly', + 'title', + 'writeOnly', +]); + +function readSchema(relativePath: string): JsonSchema { + const cached = parsedSchemas.get(relativePath); + if (cached) return cached; + const parsed = JSON.parse(readFileSync(join(schemaRoot, relativePath), 'utf8')) as JsonSchema; + parsedSchemas.set(relativePath, parsed); + return parsed; +} + +function pointerSegment(value: string): string { + return value.replaceAll('~', '~0').replaceAll('/', '~1'); +} + +function keepDefinitionKey(key: string): boolean { + return key !== '$id' + && key !== '$schema' + && !key.startsWith('x-') + && !definitionAnnotationKeys.has(key); +} + +/** Build one self-contained schema without duplicating referenced schemas or + * their absolute $ids. External AdCP refs become local refs into a shared + * $defs map; refs local to a referenced document stay scoped to that entry. */ +function bundleSchema(root: JsonSchema): JsonSchema { + const definitions = new Map(); + + function definitionRef(relativePath: string, fragment = ''): string { + return `#/$defs/${pointerSegment(relativePath)}${fragment}`; + } + + function ensureDefinition(relativePath: string): void { + if (definitions.has(relativePath)) return; + + // Reserve the entry before descending so recursive schema graphs terminate. + definitions.set(relativePath, {}); + definitions.set(relativePath, rewrite(readSchema(relativePath), relativePath) as JsonSchema); + } + + function rewrite(value: unknown, definitionPath?: string): unknown { + if (Array.isArray(value)) return value.map(entry => rewrite(entry, definitionPath)); + if (!value || typeof value !== 'object') return value; + + const record = value as JsonSchema; + const ref = record.$ref; + if (typeof ref === 'string') { + let rewrittenRef = ref; + if (ref.startsWith('/schemas/')) { + const external = ref.slice('/schemas/'.length); + const hashIndex = external.indexOf('#'); + const relativePath = hashIndex === -1 ? external : external.slice(0, hashIndex); + const fragment = hashIndex === -1 ? '' : external.slice(hashIndex + 1); + if (fragment && !fragment.startsWith('/')) { + throw new Error(`Unsupported non-pointer schema fragment: ${ref}`); + } + ensureDefinition(relativePath); + rewrittenRef = definitionRef(relativePath, fragment); + } else if (definitionPath && ref.startsWith('#')) { + const fragment = ref.slice(1); + if (fragment && !fragment.startsWith('/')) { + throw new Error(`Unsupported non-pointer local schema fragment: ${ref}`); + } + rewrittenRef = definitionRef(definitionPath, fragment); + } + + const siblings = Object.fromEntries( + Object.entries(record) + .filter(([key]) => key !== '$ref' && (!definitionPath || keepDefinitionKey(key))) + .map(([key, entry]) => [key, rewrite(entry, definitionPath)]), + ); + if (Object.keys(siblings).length === 0) return { $ref: rewrittenRef }; + + // Draft-07 ignores $ref siblings, so preserve their constraints via allOf. + return { allOf: [{ $ref: rewrittenRef }, siblings] }; + } + + return Object.fromEntries( + Object.entries(record) + .filter(([key]) => !definitionPath || keepDefinitionKey(key)) + .map(([key, entry]) => [key, rewrite(entry, definitionPath)]), + ); + } + + const bundled = rewrite(root) as JsonSchema; + if (definitions.size > 0) { + bundled.$defs = { + ...((bundled.$defs as JsonSchema | undefined) ?? {}), + ...Object.fromEntries(definitions), + }; + } + return bundled; +} + +/** Load the normative source request schema and bundle every repository-local + * reference for MCP tools/list consumers, which cannot resolve AdCP paths. */ +export function loadProductDiscoveryInputSchema(fileName: string): JsonSchema { + return bundleSchema(readSchema(`media-buy/${fileName}.json`)); +} diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 5934e927ff..6ad79ce8f0 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -16,7 +16,6 @@ import { McpError, } from '@modelcontextprotocol/sdk/types.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental/tasks'; import { PostgresTaskStore } from '@adcp/sdk'; import { canonicalFormatLegacyResolverFromCatalogSnapshots, @@ -34,7 +33,6 @@ import { type V2ProductFormatDeclaration, } from '@adcp/sdk/v2/projection'; import { mergeSeedProductLegacy as mergeSeedProduct } from '@adcp/sdk/testing'; -import { isDatabaseInitialized, getPool } from '../db/client.js'; import { createLogger } from '../logger.js'; import { isPrivateHostname, normalizeExternalHostname, safeFetchAxiosLike } from '../utils/url-security.js'; import { GET_PRODUCTS_REJECTED_ADCP_VERSION, supportsGetProductsRejected, type TrainingContext, type CatalogProduct, type MediaBuyState, type MediaBuyAvailableActionState, type MediaBuyProductAllowedActionState, type PackageState, type SignalActivationState, type CreativeState, type CreativeManifest, type ToolArgs, type ListReference, type PackageTargeting, type AccountRef, type SessionState } from './types.js'; @@ -1724,6 +1722,12 @@ import { } from './idempotency.js'; import { maybeEmitCompletionWebhook } from './webhooks.js'; import { selectSigningCapability } from './request-signing.js'; +import { + getTrainingTaskStore, + resetTrainingTaskStore, + type TrainingTaskStore, +} from './mcp-task-store.js'; +import { loadProductDiscoveryInputSchema } from './source-schema.js'; const SUPPORTED_MAJOR_VERSIONS = [3] as const; const SUPPORTED_RELEASE_VERSIONS = ['3.0', '3.1-beta.5', '3.1-beta.7', '3.1-rc.4', '3.1-rc.6', '3.1-rc.7', '3.1-rc.8', '3.1-rc.9', '3.1-rc.10', '3.1-rc.14', '3.1-rc.15', GET_PRODUCTS_REJECTED_ADCP_VERSION] as const; @@ -1907,12 +1911,16 @@ export function resolveServedAdcpVersion(args: Record): Version } export function resolveServedAdcpVersionForTool(toolName: string, args: Record): VersionResolution { + const splitProductTool = isProductDiscoveryTool(toolName) && toolName !== 'get_products'; if ( - toolName === 'validate_input' + (toolName === 'validate_input' || splitProductTool) && args.adcp_version === undefined && args.adcp_major_version === undefined ) { - return resolveServedAdcpVersion({ ...args, adcp_version: CURRENT_ADCP_VERSION }); + return resolveServedAdcpVersion({ + ...args, + adcp_version: splitProductTool ? GET_PRODUCTS_REJECTED_ADCP_VERSION : CURRENT_ADCP_VERSION, + }); } return resolveServedAdcpVersion(args); } @@ -2129,8 +2137,8 @@ function installTaskProtocolVersionNegotiation(server: Server): void { * another. This is intentional for the training agent where all sessions * are sandboxed. Production servers should scope tasks by sessionId. */ -let sdkTaskStore: InMemoryTaskStore | PostgresTaskStore | null = null; const inMemoryTaskIdsByNaturalKey = new Map(); +const IDEMPOTENT_TASK_MAX_GENERATIONS = 64; function idempotentTaskNaturalKey( principal: string, @@ -2141,41 +2149,69 @@ function idempotentTaskNaturalKey( return [principal, toolName, `success:${idempotencyKey}:${payloadHash}`].join('\0'); } -function getTaskStore(): InMemoryTaskStore | PostgresTaskStore { - if (!sdkTaskStore) { - sdkTaskStore = isDatabaseInitialized() - ? new PostgresTaskStore(getPool()) - : new InMemoryTaskStore(); - } - return sdkTaskStore; +export function idempotentTaskId(naturalKey: string, generation: number): string { + const generationKey = generation === 0 ? naturalKey : `${naturalKey}\0replacement:${generation}`; + return createHash('sha256').update(generationKey).digest('hex'); } -async function createOrReuseIdempotentTask( - taskStore: InMemoryTaskStore | PostgresTaskStore, +function taskReceiptCanBeReused(task: { status: string }): boolean { + return task.status !== 'cancelled' && task.status !== 'failed'; +} + +function isTaskIdCollision(error: unknown, taskId: string): boolean { + return error instanceof Error + && error.message === `Task with ID ${taskId} already exists. Use a different taskId or retrieve the existing task via getTask().`; +} + +export async function createOrReuseIdempotentTask( + taskStore: TrainingTaskStore, naturalKey: string, ttl: number, request: { method: string; params?: { _meta?: Record } }, ) { - const deterministicTaskId = createHash('sha256').update(naturalKey).digest('hex'); if (taskStore instanceof PostgresTaskStore) { - const existing = await taskStore.getTask(deterministicTaskId); - if (existing) return existing; - try { - return await taskStore.createTask({ ttl, taskId: deterministicTaskId }, 0, request); - } catch (error) { - // A sibling process may have inserted the same naturally keyed task - // between getTask() and createTask(). Re-read instead of allocating a - // second task or surfacing a false failure. - const raced = await taskStore.getTask(deterministicTaskId); - if (raced) return raced; - throw error; + const taskIds = Array.from( + { length: IDEMPOTENT_TASK_MAX_GENERATIONS }, + (_, generation) => idempotentTaskId(naturalKey, generation), + ); + const existingTasks = await Promise.all(taskIds.map(taskId => taskStore.getTask(taskId))); + // Prefer the newest live generation. Missing entries may be expired rows + // hidden by PostgresTaskStore, so the complete scan must happen before an + // insertion attempt; an older hole cannot prove that later generations + // do not exist. + for (let generation = existingTasks.length - 1; generation >= 0; generation -= 1) { + const existing = existingTasks[generation]; + if (existing && taskReceiptCanBeReused(existing)) return existing; + } + + for (let generation = 0; generation < taskIds.length; generation += 1) { + if (existingTasks[generation]) continue; + const deterministicTaskId = taskIds[generation]!; + try { + return await taskStore.createTask({ ttl, taskId: deterministicTaskId }, 0, request); + } catch (error) { + // A sibling process may have inserted the same naturally keyed task + // between getTask() and createTask(). Re-read instead of allocating a + // second task or surfacing a false failure. + const raced = await taskStore.getTask(deterministicTaskId); + if (raced) { + if (taskReceiptCanBeReused(raced)) return raced; + continue; + } + // getTask() filters expired rows, but their primary keys remain until + // cleanup. A confirmed SDK duplicate for an invisible row occupies + // this generation; advance instead of failing or recreating gen0. + if (isTaskIdCollision(error, deterministicTaskId)) continue; + throw error; + } } + throw new Error('Too many cancelled or failed idempotent task receipt generations'); } const priorId = inMemoryTaskIdsByNaturalKey.get(naturalKey); if (priorId) { const existing = await taskStore.getTask(priorId); - if (existing) return existing; + if (existing && taskReceiptCanBeReused(existing)) return existing; inMemoryTaskIdsByNaturalKey.delete(naturalKey); } const created = await taskStore.createTask({ ttl }, 0, request); @@ -2183,13 +2219,23 @@ async function createOrReuseIdempotentTask( return created; } -async function getIdempotentTask( - taskStore: InMemoryTaskStore | PostgresTaskStore, +export async function getIdempotentTask( + taskStore: TrainingTaskStore, naturalKey: string, ) { if (taskStore instanceof PostgresTaskStore) { - const deterministicTaskId = createHash('sha256').update(naturalKey).digest('hex'); - return taskStore.getTask(deterministicTaskId); + const tasks = await Promise.all(Array.from( + { length: IDEMPOTENT_TASK_MAX_GENERATIONS }, + (_, generation) => taskStore.getTask(idempotentTaskId(naturalKey, generation)), + )); + let latestTerminalTask: Awaited> = null; + for (let generation = tasks.length - 1; generation >= 0; generation -= 1) { + const task = tasks[generation]; + if (!task) continue; + if (taskReceiptCanBeReused(task)) return task; + if (!latestTerminalTask) latestTerminalTask = task; + } + return latestTerminalTask; } const taskId = inMemoryTaskIdsByNaturalKey.get(naturalKey); if (!taskId) return null; @@ -2314,8 +2360,7 @@ function withUsageAccountScope>(req: T): T { /** Clear the task store (for tests). Calls cleanup() to cancel TTL timers. */ export function clearTaskStore(): void { - sdkTaskStore?.cleanup(); - sdkTaskStore = null; + resetTrainingTaskStore(); inMemoryTaskIdsByNaturalKey.clear(); } @@ -2499,7 +2544,7 @@ export function hasAdcpSuccessPayload(resultObj: Record | undef } function permitsAdvisoryErrors(toolName: string, resultObj: Record | undefined): boolean { - if (toolName === 'get_products') return hasAdcpSuccessPayload(resultObj); + if (isProductDiscoveryTool(toolName)) return hasAdcpSuccessPayload(resultObj); if (toolName === 'create_media_buy') { return resultObj?.status === 'submitted' && typeof resultObj.task_id === 'string'; } @@ -4043,10 +4088,169 @@ function signalMatchesRef( // ── Tool definitions ────────────────────────────────────────────── +const PRODUCT_DISCOVERY_TOOLS = new Set([ + 'get_products', + 'list_products', + 'recommend_products', + 'refine_proposal', + 'finalize_proposals', +]); + +function isProductDiscoveryTool(toolName: string): boolean { + return PRODUCT_DISCOVERY_TOOLS.has(toolName); +} + +export function canonicalProductDiscoveryTool(toolName: string): string { + return isProductDiscoveryTool(toolName) ? 'get_products' : toolName; +} + +/** + * Normalize every AdCP 3.2 split product task to the stable 3.x get_products + * logical payload before validation, idempotency, task recovery, and handler + * dispatch. This makes a retry safe even when a caller switches between the + * legacy facade and its canonical 3.2 alias after an ambiguous failure. + */ +export function normalizeProductDiscoveryArgs( + toolName: string, + args: Record, +): Record { + if (toolName === 'list_products') { + const { buying_mode: _buyingMode, brief: _brief, refine: _refine, ...rest } = args; + return { ...rest, buying_mode: 'wholesale' }; + } + if (toolName === 'recommend_products') { + const { buying_mode: _buyingMode, refine: _refine, ...rest } = args; + return { ...rest, buying_mode: 'brief' }; + } + if (toolName === 'refine_proposal') { + const { buying_mode: _buyingMode, ...rest } = args; + return { ...rest, buying_mode: 'refine' }; + } + if (toolName === 'finalize_proposals') { + const { + buying_mode: _buyingMode, + refine: _refine, + proposal_ids: proposalIds, + ...rest + } = args; + return { + ...rest, + buying_mode: 'refine', + refine: Array.isArray(proposalIds) + ? proposalIds.map(proposalId => ({ scope: 'proposal', proposal_id: proposalId, action: 'finalize' })) + : [], + }; + } + return args; +} + +function idempotencyPayloadForServedVersion( + toolName: string, + args: Record, + servedAdcpVersion: string, +): Record { + if ( + isProductDiscoveryTool(toolName) + && args.adcp_version === undefined + && args.adcp_major_version === undefined + ) { + // The legacy facade defaults to 3.0 while split names default to 3.2. + // Bind an omitted caller pin to the effective release so those distinct + // wire contracts conflict instead of replaying a response across versions. + return { ...args, adcp_version: servedAdcpVersion }; + } + return args; +} + +export function validateProductDiscoveryAliasInput( + toolName: string, + args: Record, +): { message: string; field?: string } | undefined { + if ( + (toolName === 'recommend_products' || toolName === 'refine_proposal' || toolName === 'finalize_proposals') + && args.idempotency_key == null + ) { + return { message: `idempotency_key is required for ${toolName}`, field: 'idempotency_key' }; + } + if (toolName === 'list_products') { + for (const field of ['buying_mode', 'brief', 'refine']) { + if (args[field] !== undefined) return { message: `${field} is not valid for list_products`, field }; + } + if (args.if_pricing_version !== undefined && args.if_wholesale_feed_version === undefined) { + return { message: 'if_pricing_version requires if_wholesale_feed_version', field: 'if_wholesale_feed_version' }; + } + } + if (toolName === 'recommend_products') { + if (typeof args.brief !== 'string' || args.brief.length === 0) { + return { message: 'brief is required for recommend_products', field: 'brief' }; + } + for (const field of ['buying_mode', 'refine', 'if_wholesale_feed_version', 'if_pricing_version']) { + if (args[field] !== undefined) return { message: `${field} is not valid for recommend_products`, field }; + } + } + if (toolName === 'refine_proposal') { + if (!Array.isArray(args.refine) || args.refine.length === 0) { + return { message: 'refine must contain at least one change', field: 'refine' }; + } + const proposalIds = new Set(); + for (let index = 0; index < args.refine.length; index += 1) { + const entry = args.refine[index]; + if (!isRecord(entry)) continue; + if (entry.action === 'finalize') { + return { message: 'action finalize is only valid on finalize_proposals', field: `refine[${index}].action` }; + } + if (entry.scope === 'proposal' && typeof entry.proposal_id === 'string') proposalIds.add(entry.proposal_id); + } + if (proposalIds.size === 0) { + return { message: 'refine_proposal requires at least one proposal-scoped change', field: 'refine' }; + } + if (proposalIds.size > 1) { + return { message: 'refine_proposal may target only one proposal_id', field: 'refine' }; + } + for (const field of ['buying_mode', 'brief', 'if_wholesale_feed_version', 'if_pricing_version']) { + if (args[field] !== undefined) return { message: `${field} is not valid for refine_proposal`, field }; + } + } + if (toolName === 'finalize_proposals') { + if (!Array.isArray(args.proposal_ids) || args.proposal_ids.length === 0) { + return { message: 'proposal_ids must contain at least one proposal ID', field: 'proposal_ids' }; + } + if (args.proposal_ids.some(proposalId => typeof proposalId !== 'string' || proposalId.length === 0)) { + return { message: 'proposal_ids entries must be non-empty strings', field: 'proposal_ids' }; + } + if (new Set(args.proposal_ids).size !== args.proposal_ids.length) { + return { message: 'proposal_ids entries must be unique', field: 'proposal_ids' }; + } + for (const field of [ + 'buying_mode', + 'brief', + 'refine', + 'catalog', + 'filters', + 'fields', + 'preferred_delivery_types', + 'property_list', + 'required_policies', + 'if_wholesale_feed_version', + 'if_pricing_version', + 'pagination', + 'time_budget', + ]) { + if (args[field] !== undefined) return { message: `${field} is not valid for finalize_proposals`, field }; + } + } + return undefined; +} + +const LIST_PRODUCTS_INPUT_SCHEMA = loadProductDiscoveryInputSchema('list-products-request'); +const RECOMMEND_PRODUCTS_INPUT_SCHEMA = loadProductDiscoveryInputSchema('recommend-products-request'); +const REFINE_PROPOSAL_INPUT_SCHEMA = loadProductDiscoveryInputSchema('refine-proposal-request'); +const FINALIZE_PROPOSALS_INPUT_SCHEMA = loadProductDiscoveryInputSchema('finalize-proposals-request'); + const TOOLS = [ { name: 'get_products', - description: 'Discover available advertising products. Supports brief (curated discovery), wholesale (raw catalog), and refine (iterate on previous results) buying modes. Use this before create_media_buy to find valid product_id and pricing_option_id values. Not for checking delivery or managing existing buys. Returns sandbox catalog data.', + description: 'DEPRECATED in AdCP 3.2. Compatibility facade for brief, wholesale, refine, and finalize product flows. New callers use list_products, recommend_products, refine_proposal, and finalize_proposals.', // Polymorphic: brief/wholesale can be reads, but Submitted responses // allocate a task and refine+finalize commits an inventory hold. annotations: { readOnlyHint: false, idempotentHint: true }, @@ -4077,9 +4281,37 @@ const TOOLS = [ }, }, }, - required: ['idempotency_key', 'buying_mode'], + required: ['buying_mode'], }, }, + { + name: 'list_products', + description: 'List the synchronous wholesale product feed. This is the side-effect-free AdCP 3.2 replacement for get_products with buying_mode "wholesale".', + annotations: { readOnlyHint: true, idempotentHint: true }, + execution: { taskSupport: 'forbidden' as const }, + inputSchema: LIST_PRODUCTS_INPUT_SCHEMA, + }, + { + name: 'recommend_products', + description: 'Request curated product recommendations from a campaign brief. May complete asynchronously. AdCP 3.2 replacement for get_products with buying_mode "brief".', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + execution: { taskSupport: 'optional' as const }, + inputSchema: RECOMMEND_PRODUCTS_INPUT_SCHEMA, + }, + { + name: 'refine_proposal', + description: 'Refine one draft proposal without committing it. AdCP 3.2 replacement for non-finalizing get_products refine calls.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + execution: { taskSupport: 'optional' as const }, + inputSchema: REFINE_PROPOSAL_INPUT_SCHEMA, + }, + { + name: 'finalize_proposals', + description: 'Atomically commit one or more draft proposals to firm pricing and inventory holds. AdCP 3.2 replacement for exclusive get_products finalize refinements.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + execution: { taskSupport: 'optional' as const }, + inputSchema: FINALIZE_PROPOSALS_INPUT_SCHEMA, + }, { name: 'list_creative_formats', description: 'DEPRECATED in AdCP 3.2. Legacy named-format compatibility projection only. Sales deliverability comes from get_products format_options[]; creative-agent operations come from get_adcp_capabilities creative.supported_formats[].', @@ -4512,21 +4744,43 @@ const TOOLS = [ }, ]; +/** + * Return the exact split product-discovery definitions used by the native + * training server. Tenant discovery projects these same objects so the two + * MCP entry points cannot advertise different request contracts. + */ +export function productDiscoveryAliasToolDefinitions(): Array<(typeof TOOLS)[number]> { + return structuredClone(TOOLS.filter(tool => ( + tool.name === 'list_products' + || tool.name === 'recommend_products' + || tool.name === 'refine_proposal' + || tool.name === 'finalize_proposals' + ))); +} + function visibleToolsForContext(ctx: TrainingContext): typeof TOOLS { const threeZero = isThreeZeroStoryboardCompat(ctx); return TOOLS - .filter(tool => tool.name !== 'validate_input' || !threeZero) + .filter(tool => !threeZero || ( + tool.name !== 'validate_input' + && (tool.name === 'get_products' || !isProductDiscoveryTool(tool.name)) + )) .map(tool => { if (!threeZero || tool.name !== 'get_products') return tool; - const properties = { ...tool.inputSchema.properties }; - delete (properties as Record).idempotency_key; + const inputSchema = tool.inputSchema as { + properties?: Record; + required?: unknown[]; + [key: string]: unknown; + }; + const properties = { ...(inputSchema.properties ?? {}) }; + delete properties.idempotency_key; return { ...tool, annotations: { ...tool.annotations, readOnlyHint: true, idempotentHint: true }, inputSchema: { - ...tool.inputSchema, + ...inputSchema, properties, - required: tool.inputSchema.required?.filter(field => field !== 'idempotency_key'), + required: inputSchema.required?.filter(field => field !== 'idempotency_key'), }, }; }) as typeof TOOLS; @@ -4537,7 +4791,11 @@ export function visibleTrainingToolNamesForContext(ctx: TrainingContext): string } function toolAvailableForServedAdcpVersion(toolName: string, servedAdcpVersion: string): boolean { - return !(toolName === 'validate_input' && servedAdcpVersion.startsWith('3.0')); + if (toolName === 'validate_input') return !servedAdcpVersion.startsWith('3.0'); + if (isProductDiscoveryTool(toolName) && toolName !== 'get_products') { + return supportsGetProductsRejected(servedAdcpVersion); + } + return true; } // ── Task handler implementations ────────────────────────────────── @@ -8614,6 +8872,9 @@ export async function handleGetAdcpCapabilities(args: ToolArgs, ctx: TrainingCon }), media_buy: { buying_modes: wholesaleProfile.productWholesale ? ['brief', 'wholesale', 'refine'] : ['brief', 'refine'], + ...(supportsGetProductsRejected(servedAdcpVersion) && { + product_discovery_tools: [...PRODUCT_DISCOVERY_TOOLS], + }), supports_proposals: true, features: { inline_creative_management: true, @@ -10479,6 +10740,10 @@ type ToolHandler = (args: ToolArgs, ctx: TrainingContext) => object | Promise = { get_products: handleGetProducts, + list_products: handleGetProducts, + recommend_products: handleGetProducts, + refine_proposal: handleGetProducts, + finalize_proposals: handleGetProducts, list_creative_formats: handleListCreativeFormats, validate_input: handleValidateInput, create_media_buy: handleCreateMediaBuy, @@ -10592,7 +10857,7 @@ function applyThreeZeroGetProductsIdempotencyCompatibility( return args; } - // Frozen 3.0 get_products examples predate the now-required key. Give only + // Frozen 3.0 get_products examples predate the shared replay contract. Give only // that legacy wire shape a deterministic internal key so exact retries still // converge through the normal schema/cache/task/finalize path. Context and // version negotiation are envelope concerns, not logical request identity. @@ -10655,12 +10920,20 @@ async function executeTrainingAgentToolInContext( if (!handler) { return { success: false, error: `Unknown tool: ${toolName}` }; } + if (isMutatingTool(toolName) && initialHandlerArgs.idempotency_key == null) { + return { success: false, error: `idempotency_key is required for ${toolName}` }; + } + const aliasValidationError = validateProductDiscoveryAliasInput(toolName, initialHandlerArgs); + if (aliasValidationError) { + return { success: false, error: aliasValidationError.message }; + } + const normalizedHandlerArgs = normalizeProductDiscoveryArgs(toolName, initialHandlerArgs); const authPrincipal = ctx.principal ?? ctx.userId ?? 'anonymous'; let accountScope: string | undefined; try { accountScope = toolName === 'comply_test_controller' ? undefined - : deriveAccountScope(initialHandlerArgs, toolName === 'get_products'); + : deriveAccountScope(normalizedHandlerArgs, isProductDiscoveryTool(toolName)); } catch (error) { if (!(error instanceof AccountRefValidationError)) throw error; return { success: false, error: `Invalid ${toolName} request at account: ${error.message}` }; @@ -10668,34 +10941,42 @@ async function executeTrainingAgentToolInContext( const principal = scopedPrincipal(authPrincipal, accountScope); const handlerArgs = applyThreeZeroGetProductsIdempotencyCompatibility( toolName, - initialHandlerArgs, + normalizedHandlerArgs, principal, ctx.storyboardCompat?.version === '3.0' || initialHandlerArgs.adcp_version === '3.0', ); const idempotencyKey = handlerArgs.idempotency_key; let claim: { payloadHash: string; claimToken: string } | undefined; - if (isMutatingTool(toolName)) { - if (idempotencyKey === undefined || idempotencyKey === null) { + // The read-only legacy/list surfaces permit keyless calls, but they still + // share the canonical get_products request contract. Validate the logical + // request before deciding whether this attempt participates in replay. + const productValidationError = validateIdempotencyProtectedInput( + canonicalProductDiscoveryTool(toolName), + handlerArgs, + ); + if (productValidationError) { + return { success: false, error: productValidationError.message }; + } + + if (isMutatingTool(toolName) || idempotencyKey !== undefined) { + if (isMutatingTool(toolName) && (idempotencyKey === undefined || idempotencyKey === null)) { return { success: false, error: `idempotency_key is required for ${toolName}` }; } if (!validateKeyFormat(idempotencyKey)) { return { success: false, error: 'idempotency_key has an invalid format' }; } - const validationError = validateIdempotencyProtectedInput(toolName, handlerArgs); - if (validationError) { - return { success: false, error: validationError.message }; - } const outcome = await getIdempotencyStore().check({ principal, key: idempotencyKey, - payload: handlerArgs, + payload: idempotencyPayloadForServedVersion(toolName, handlerArgs, versionResolution.servedVersion), }); if (outcome.kind === 'replay') { return { success: true, data: { ...(outcome.response as object), + adcp_version: versionResolution.servedVersion, replayed: true, ...(callerContext !== undefined && { context: callerContext }), }, @@ -10736,7 +11017,7 @@ async function executeTrainingAgentToolInContext( // published. Other tools retain the historical save-then-flush order; // moving every synchronous mutation to flush-first creates a new // duplicate-execution window if cache publication fails. - if (toolName === 'get_products') await flushDirtySessions(); + if (isProductDiscoveryTool(toolName)) await flushDirtySessions(); await getIdempotencyStore().save({ principal, key: idempotencyKey, @@ -10766,7 +11047,7 @@ async function executeTrainingAgentToolInContext( * Create a per-request MCP Server with training agent tools. */ export function createTrainingAgentServer(ctx: TrainingContext): Server { - const taskStore = getTaskStore(); + const taskStore = getTrainingTaskStore(); const server = new Server( { name: 'adcp-training-agent', version: '1.0.0' }, { @@ -10837,6 +11118,30 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { return { result: adcpError('INVALID_REQUEST', { message: `Unknown tool: ${name}` }, callerContext, servedAdcpVersion), flushable: true }; } + if (isMutatingTool(name) && initialHandlerArgs.idempotency_key == null) { + return { + result: adcpError('INVALID_REQUEST', { + message: `idempotency_key is required for ${name}. Generate a UUID v4 and reuse it unchanged for retries.`, + field: 'idempotency_key', + recovery: 'correctable', + }, callerContext, servedAdcpVersion), + flushable: true, + }; + } + + const aliasValidationError = validateProductDiscoveryAliasInput(name, initialHandlerArgs); + if (aliasValidationError) { + return { + result: adcpError('INVALID_REQUEST', { + message: aliasValidationError.message, + ...(aliasValidationError.field && { field: aliasValidationError.field }), + recovery: 'correctable', + }, callerContext, servedAdcpVersion), + flushable: true, + }; + } + const normalizedHandlerArgs = normalizeProductDiscoveryArgs(name, initialHandlerArgs); + // Check for task-augmented request (explicit `task` field in params). // Dry-run requests always return synchronously — there's no reason to // async a dry-run operation, and clients expect immediate results. @@ -10863,7 +11168,7 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { try { accountScope = name === 'comply_test_controller' ? undefined - : deriveAccountScope(initialHandlerArgs, name === 'get_products'); + : deriveAccountScope(normalizedHandlerArgs, isProductDiscoveryTool(name)); } catch (error) { if (!(error instanceof AccountRefValidationError)) throw error; return { @@ -10878,7 +11183,7 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { const idempotencyPrincipal = scopedPrincipal(authPrincipal, accountScope); const handlerArgs = applyThreeZeroGetProductsIdempotencyCompatibility( name, - initialHandlerArgs, + normalizedHandlerArgs, idempotencyPrincipal, ctx.storyboardCompat?.version === '3.0' || initialHandlerArgs.adcp_version === '3.0', ); @@ -10892,10 +11197,27 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { let idempotencyClaimed = false; let idempotencyClaimToken: string | undefined; let idempotencyReplayed = false; - let idempotencyReplayResponse: Record | undefined; - if (isMutatingTool(name)) { - if (idempotencyKey === undefined || idempotencyKey === null) { + // Product reads may omit an idempotency key, but keylessness must never + // weaken their request validation. The split names normalize to the same + // canonical get_products contract before this check. + const productValidationError = validateIdempotencyProtectedInput( + canonicalProductDiscoveryTool(name), + handlerArgs, + ); + if (productValidationError) { + return { + result: adcpError('INVALID_REQUEST', { + message: productValidationError.message, + ...(productValidationError.field && { field: productValidationError.field }), + recovery: 'correctable', + }, callerContext, servedAdcpVersion), + flushable: true, + }; + } + + if (isMutatingTool(name) || idempotencyKey !== undefined) { + if (isMutatingTool(name) && (idempotencyKey === undefined || idempotencyKey === null)) { return { result: adcpError('INVALID_REQUEST', { message: `idempotency_key is required for ${name}. Generate a UUID v4 and include it on every mutating request; reuse the same key for network retries.`, @@ -10915,22 +11237,12 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { flushable: true, }; } - const validationError = validateIdempotencyProtectedInput(name, handlerArgs); - if (validationError) { - return { - result: adcpError('INVALID_REQUEST', { - message: validationError.message, - ...(validationError.field && { field: validationError.field }), - recovery: 'correctable', - }, callerContext, servedAdcpVersion), - flushable: true, - }; - } const store = getIdempotencyStore(); + const idempotencyPayload = idempotencyPayloadForServedVersion(name, handlerArgs, servedAdcpVersion); const outcome = await store.check({ principal: idempotencyPrincipal, key: idempotencyKey, - payload: handlerArgs, + payload: idempotencyPayload, }); if (outcome.kind === 'expired') { return { @@ -10982,9 +11294,10 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { content: [{ type: 'text', text: JSON.stringify(body) }], structuredContent: body, }; + cachableResponse = { ...(outcome.response as Record) }; + idempotencyPayloadHash = payloadHash(idempotencyPayload); skipHandler = true; idempotencyReplayed = true; - idempotencyReplayResponse = body; } else { // 'miss' → the store reserved the claim via putIfAbsent. We must // call save() on success or release() on any other path so the @@ -11001,20 +11314,40 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { if (isTaskRequest) { const naturalKey = idempotentTaskNaturalKey( idempotencyPrincipal, - name, + canonicalProductDiscoveryTool(name), idempotencyKey, idempotencyPayloadHash, ); try { const recoveredTask = await getIdempotentTask(taskStore, naturalKey); if (recoveredTask) { + if (recoveredTask.status === 'cancelled' || recoveredTask.status === 'failed') { + // Cancellation is terminal buyer intent, and failed task + // results are deliberately not cached. Never rerun either + // receipt as crash recovery: doing so could publish a success + // cache/webhook that the returned terminal task cannot expose. + await store.release({ + principal: idempotencyPrincipal, + key: idempotencyKey, + claimToken: idempotencyClaimToken, + }); + return { + result: { + task: recoveredTask, + adcp_version: servedAdcpVersion, + replayed: true, + ...(callerContext !== undefined && { context: callerContext }), + }, + flushable: false, + }; + } if (recoveredTask.status !== 'completed') { // get_products commits converge on the exact stored proposal // (including IO and expiry), so it is safe to repair an // orphaned deterministic task by rerunning the handler and // storing the result into the same task below. Other mutators // may have non-reconstructable random IDs and must fail closed. - if (name !== 'get_products') { + if (!isProductDiscoveryTool(name)) { throw new Error(`Prior idempotent task ${recoveredTask.taskId} is not recoverable in status ${recoveredTask.status}`); } } else { @@ -11026,21 +11359,21 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { throw new Error(`Prior idempotent task ${recoveredTask.taskId} has no successful structured result`); } + const { + context: _cachedContext, + replayed: _cachedReplayMarker, + ...notificationResponse + } = recoveredBody; const taskResponse = { task: recoveredTask, adcp_version: servedAdcpVersion }; await store.save({ principal: idempotencyPrincipal, key: idempotencyKey, payloadHash: idempotencyPayloadHash, - response: taskResponse, + response: notificationResponse, claimToken: idempotencyClaimToken, }); - const { - context: _cachedContext, - replayed: _cachedReplayMarker, - ...notificationResponse - } = recoveredBody; maybeEmitCompletionWebhook({ - toolName: name, + toolName: canonicalProductDiscoveryTool(name), args: handlerArgs, response: notificationResponse, requestIdempotencyKey: idempotencyKey, @@ -11188,7 +11521,7 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { return false; } try { - const flushedBeforeSave = name === 'get_products'; + const flushedBeforeSave = isProductDiscoveryTool(name); if (flushedBeforeSave) await flushDirtySessions(); await store.save({ principal: idempotencyPrincipal, @@ -11209,9 +11542,15 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { }; const emitCompletionWebhook = (): void => { - if (cachableResponse === null || toolResult!.isError || handlerThrew) return; + if ( + name === 'list_products' + || idempotencyReplayed + || cachableResponse === null + || toolResult!.isError + || handlerThrew + ) return; maybeEmitCompletionWebhook({ - toolName: name, + toolName: canonicalProductDiscoveryTool(name), args: handlerArgs, response: cachableResponse, requestIdempotencyKey: typeof idempotencyKey === 'string' ? idempotencyKey : undefined, @@ -11232,12 +11571,6 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { return { result: toolResult, flushable: !handlerThrew && !flushed }; } - // Exact task replay returns the originally cached task envelope and never - // reaches createTask(). This remains true after the task becomes terminal. - if (idempotencyReplayed) { - return { result: idempotencyReplayResponse ?? toolResult, flushable: false }; - } - // Ordinary/error tasks honor the requested TTL up to the training-agent // cap. A successful idempotency-protected task is different: its terminal // record is the crash-recovery receipt if cache publication fails. MCP @@ -11263,18 +11596,32 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { // committed proposal and converges on the same IO/expiry. await flushDirtySessions(); const canReuseNaturalTask = ( - idempotencyClaimed - && typeof idempotencyKey === 'string' + typeof idempotencyKey === 'string' && typeof idempotencyPayloadHash === 'string' && cachableResponse !== null && !toolResult.isError && !handlerThrew ); - const taskRequest = request as unknown as { method: string; params?: { _meta?: Record } }; + const rawTaskRequest = request as unknown as { method: string; params?: Record }; + const taskRequest = isProductDiscoveryTool(name) + ? { + ...rawTaskRequest, + params: { + ...rawTaskRequest.params, + name: canonicalProductDiscoveryTool(name), + arguments: handlerArgs, + }, + } + : rawTaskRequest; const created = canReuseNaturalTask ? await createOrReuseIdempotentTask( taskStore, - idempotentTaskNaturalKey(idempotencyPrincipal, name, idempotencyKey!, idempotencyPayloadHash!), + idempotentTaskNaturalKey( + idempotencyPrincipal, + canonicalProductDiscoveryTool(name), + idempotencyKey!, + idempotencyPayloadHash!, + ), IDEMPOTENT_TASK_RECEIPT_TTL, taskRequest, ) @@ -11298,8 +11645,13 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { 'Created MCP task', ); - const taskResponse = { task, adcp_version: servedAdcpVersion } as Record; - const flushed = await resolveIdempotencyClaim(cachableResponse === null ? null : taskResponse); + const taskResponse = { + task, + adcp_version: servedAdcpVersion, + ...(idempotencyReplayed && { replayed: true }), + ...(callerContext !== undefined && { context: callerContext }), + } as Record; + const flushed = await resolveIdempotencyClaim(cachableResponse); emitCompletionWebhook(); return { result: taskResponse, flushable: !handlerThrew && !flushed }; } diff --git a/server/src/training-agent/tenants/registry.ts b/server/src/training-agent/tenants/registry.ts index 85216faad4..8f7171a412 100644 --- a/server/src/training-agent/tenants/registry.ts +++ b/server/src/training-agent/tenants/registry.ts @@ -50,6 +50,7 @@ import { createLogger } from '../../logger.js'; import type { TrainingContext } from '../types.js'; import { getCanonicalBase } from '../canonical-base.js'; import { creativeProjectionAdapters } from '../task-handlers.js'; +import { sharedTrainingTaskStore } from '../mcp-task-store.js'; export { getCanonicalBase } from '../canonical-base.js'; @@ -196,6 +197,7 @@ function buildDefaultServerOptions(storyboardCompat?: TrainingContext['storyboar // while its consumers migrate to inline-terminal handling. autoEmitCompletionWebhooks: true, taskRegistry: pickTaskRegistry(), + taskStore: sharedTrainingTaskStore, stateStore: pickStateStore(), mergeSeam: 'log-once', // Keep the SDK facade's legacy and canonical wire projections on the diff --git a/server/src/training-agent/tenants/router.ts b/server/src/training-agent/tenants/router.ts index 4b64e741e1..5ee99fda40 100644 --- a/server/src/training-agent/tenants/router.ts +++ b/server/src/training-agent/tenants/router.ts @@ -16,12 +16,24 @@ import { createLogger } from '../../logger.js'; import { runWithSessionContext, flushDirtySessions } from '../state.js'; import { createRegistryHolder, getCanonicalBase, resolveTenantHost, type RegistryHolder } from './registry.js'; import { buildSignedRevocationList } from '../governance-revocations.js'; -import { salesCapabilityProjection } from '../v6-sales-platform.js'; +import { + resolveTrainingSalesRequestContext, + salesCapabilityProjection, +} from '../v6-sales-platform.js'; import { handleComplyTestController } from '../comply-test-controller.js'; -import { adcpError, resolveServedAdcpVersion, supportedCanonicalFormatsCapability } from '../task-handlers.js'; -import { GET_PRODUCTS_REJECTED_ADCP_VERSION, type TrainingContext } from '../types.js'; +import { + adcpError, + createTrainingAgentServer, + productDiscoveryAliasToolDefinitions, + resolveServedAdcpVersion, + resolveServedAdcpVersionForTool, + supportedCanonicalFormatsCapability, + validateProductDiscoveryAliasInput, +} from '../task-handlers.js'; +import { GET_PRODUCTS_REJECTED_ADCP_VERSION, supportsGetProductsRejected, type TrainingContext } from '../types.js'; import { getAgentUrl } from '../config.js'; import { redactConflictEnvelopeInBody } from '../conflict-envelope.js'; +import { canonicalizeAccountRef } from '../account-scope.js'; const logger = createLogger('training-agent-tenant-router'); const PRODUCT_WHOLESALE_EVENTS = ['product.created', 'product.updated', 'product.priced', 'product.removed'] as const; @@ -118,6 +130,13 @@ const SALES_CURRENT_SCENARIOS = [ const TRAINING_AGENT_SUPPORTED_RELEASE_VERSIONS = ['3.0', '3.1-beta.5', '3.1-beta.7', '3.1-rc.4', '3.1-rc.6', '3.1-rc.7', '3.1-rc.8', '3.1-rc.9', '3.1-rc.10', '3.1-rc.14', '3.1-rc.15', GET_PRODUCTS_REJECTED_ADCP_VERSION] as const; const TRAINING_AGENT_DEFAULT_ADCP_VERSION = '3.0'; +const PRODUCT_DISCOVERY_TOOL_NAMES = [ + 'get_products', + 'list_products', + 'recommend_products', + 'refine_proposal', + 'finalize_proposals', +] as const; function bearerToken(req: Request): string | undefined { const auth = req.headers.authorization; @@ -356,6 +375,9 @@ function tenantMcpHandler(holder: RegistryHolder, tenantId: string, storyboardCo if (await tryHandleLocalComplyScenario(req, res, resolved.tenantId, principal, storyboardCompat)) { return; } + if (await tryHandleProductDiscoveryRequest(req, res, resolved.tenantId, principal, storyboardCompat)) { + return; + } const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, @@ -388,6 +410,114 @@ function tenantMcpHandler(holder: RegistryHolder, tenantId: string, storyboardCo }; } +async function tryHandleProductDiscoveryRequest( + req: Request, + res: Response, + tenantId: string, + principal: string | undefined, + storyboardCompat?: TrainingContext['storyboardCompat'], +): Promise { + if (tenantId !== 'sales') return false; + if (storyboardCompat?.version === '3.0') return false; + + const method = req.body?.method; + const toolName = req.body?.params?.name; + const rawArgs = (req.body?.params?.arguments ?? {}) as Record; + const requestedProductVersion = method === 'tools/call' && PRODUCT_DISCOVERY_TOOL_NAMES.includes(toolName) + ? resolveServedAdcpVersionForTool(toolName, rawArgs) + : undefined; + const isProductCall = ( + method === 'tools/call' + && PRODUCT_DISCOVERY_TOOL_NAMES.includes(toolName) + && ( + toolName !== 'get_products' + || (requestedProductVersion?.ok === true && supportsGetProductsRejected(requestedProductVersion.servedVersion)) + ) + ); + const isTaskLifecycleCall = ( + method === 'tasks/get' + || method === 'tasks/result' + || method === 'tasks/list' + || method === 'tasks/cancel' + ); + if (!isProductCall && !isTaskLifecycleCall) return false; + + if (isProductCall && toolName !== 'get_products') { + const validationError = validateProductDiscoveryAliasInput(toolName, rawArgs); + if (validationError) { + res.json({ + jsonrpc: '2.0', + id: req.body.id ?? null, + error: { + code: -32602, + message: validationError.message, + data: validationError.field ? { field: validationError.field } : undefined, + }, + }); + return true; + } + + } + + if (isProductCall && rawArgs.account !== undefined) { + try { + canonicalizeAccountRef(rawArgs.account as never); + } catch (error) { + res.json({ + jsonrpc: '2.0', + id: req.body.id ?? null, + result: adcpError('INVALID_REQUEST', { + message: error instanceof Error ? error.message : 'account is malformed', + field: 'account', + recovery: 'correctable', + }, rawArgs.context, requestedProductVersion?.ok ? requestedProductVersion.servedVersion : undefined), + }); + return true; + } + } + + let nativeContext: TrainingContext; + try { + nativeContext = isProductCall + ? await resolveTrainingSalesRequestContext( + req.body.params.arguments as Record, + (req as unknown as { + auth?: { clientId?: string; scopes?: string[]; extra?: Record }; + }).auth, + storyboardCompat, + ) + : { + mode: 'open' as const, + tenantId: 'sales' as const, + principal: principal ?? 'anonymous', + }; + } catch (error) { + logger.error({ error, toolName }, 'Native sales request context resolution failed'); + res.json({ + jsonrpc: '2.0', + id: req.body.id ?? null, + result: adcpError('SERVICE_UNAVAILABLE', { + message: 'Unable to resolve the sales request context', + recovery: 'transient', + }, rawArgs.context, requestedProductVersion?.ok ? requestedProductVersion.servedVersion : undefined), + }); + return true; + } + const nativeServer = createTrainingAgentServer(nativeContext); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + try { + await nativeServer.connect(transport); + installConflictEnvelopeRedaction(res); + await transport.handleRequest(req, res, req.body); + } finally { + await nativeServer.close().catch(() => {}); + } + return true; +} + async function tryHandleLocalComplyScenario( req: Request, res: Response, @@ -545,6 +675,7 @@ function projectTenantToolDiscovery( [key: string]: unknown; }; annotations?: Record; + execution?: { taskSupport?: string }; }>; }; }; @@ -573,15 +704,20 @@ function projectTenantToolDiscovery( getProducts.inputSchema = { ...inputSchema, properties, - required: isThreeZeroCompat - ? required.filter(field => field !== 'idempotency_key') - : [...new Set([...required, 'idempotency_key'])], + required: required.filter(field => field !== 'idempotency_key'), }; getProducts.annotations = { ...(getProducts.annotations ?? {}), readOnlyHint: isThreeZeroCompat, idempotentHint: true, }; + + if (!isThreeZeroCompat) { + const splitTools = productDiscoveryAliasToolDefinitions(); + for (const splitTool of splitTools) { + if (!tools.some(tool => tool.name === splitTool.name)) tools.push(splitTool); + } + } } } if (storyboardCompat?.version === '3.0') { @@ -691,6 +827,9 @@ function projectSalesCapabilities( structured.media_buy = { ...mediaBuy, ...salesProjection, + ...(supportsGetProductsRejected(servedVersion) && { + product_discovery_tools: [...PRODUCT_DISCOVERY_TOOL_NAMES], + }), features: { ...( mediaBuy.features && typeof mediaBuy.features === 'object' diff --git a/server/src/training-agent/tenants/tenant-smoke.test.ts b/server/src/training-agent/tenants/tenant-smoke.test.ts index fa7e39041a..eb1b6dadcc 100644 --- a/server/src/training-agent/tenants/tenant-smoke.test.ts +++ b/server/src/training-agent/tenants/tenant-smoke.test.ts @@ -1490,7 +1490,7 @@ describe('tenant routing smoke', () => { await close(); } }, 20000); - it('enforces and replays idempotency on polymorphic sales get_products', async () => { + it('keeps get_products compatible and replays across the AdCP 3.2 split aliases', async () => { const { baseUrl, close } = await bootServer(); try { const url = `${baseUrl}/sales/mcp`; @@ -1501,6 +1501,7 @@ describe('tenant routing smoke', () => { }; const payload = { idempotency_key: 'tenant-products-idempotency-0001', + adcp_version: '3.2-beta.0', buying_mode: 'wholesale', account, }; @@ -1521,13 +1522,15 @@ describe('tenant routing smoke', () => { inputSchema?: { properties?: Record>; required?: string[]; + $defs?: Record>; }; annotations?: Record; + execution?: { taskSupport?: string }; }>; }; }; const discovered = listBody.result?.tools?.find(tool => tool.name === 'get_products'); - expect(discovered?.inputSchema?.required).toContain('idempotency_key'); + expect(discovered?.inputSchema?.required).not.toContain('idempotency_key'); expect(discovered?.inputSchema?.properties?.idempotency_key).toMatchObject({ type: 'string', minLength: 16, @@ -1536,12 +1539,77 @@ describe('tenant routing smoke', () => { }); expect(discovered?.annotations).toMatchObject({ readOnlyHint: false, idempotentHint: true }); - const missing = await callTenantTool(url, 3, 'get_products', { + expect(listBody.result?.tools?.map(tool => tool.name)).toEqual(expect.arrayContaining([ + 'list_products', + 'recommend_products', + 'refine_proposal', + 'finalize_proposals', + ])); + const listAlias = listBody.result?.tools?.find(tool => tool.name === 'list_products'); + const recommendAlias = listBody.result?.tools?.find(tool => tool.name === 'recommend_products'); + expect(listAlias?.execution).toEqual({ taskSupport: 'forbidden' }); + expect(listAlias?.inputSchema).toMatchObject({ dependencies: { catalog: ['brand'] } }); + expect(recommendAlias?.execution).toEqual({ taskSupport: 'optional' }); + expect(recommendAlias?.inputSchema).toMatchObject({ + dependencies: { catalog: ['brand'] }, + properties: { brief: { type: 'string', minLength: 1 } }, + }); + const refineAlias = listBody.result?.tools?.find(tool => tool.name === 'refine_proposal'); + expect(refineAlias?.inputSchema?.properties?.refine).toMatchObject({ + allOf: expect.arrayContaining([ + { $ref: '#/$defs/media-buy~1product-refinement.json' }, + ]), + }); + expect(refineAlias?.inputSchema?.$defs?.['media-buy/product-refinement.json']) + .toMatchObject({ type: 'array', minItems: 1 }); + + const keylessLegacy = await callTenantTool(url, 3, 'get_products', { buying_mode: 'wholesale', account, + }) as { result?: { structuredContent?: { products?: unknown[] } } }; + expect(keylessLegacy.result?.structuredContent?.products?.length).toBeGreaterThan(0); + + const invalidKeylessLegacy = await callTenantTool(url, 31, 'get_products', { + buying_mode: 'not-a-mode', + account, }) as { result?: { structuredContent?: { adcp_error?: { code?: string; field?: string } } } }; - expect(missing.result?.structuredContent?.adcp_error?.code).toBe('INVALID_REQUEST'); - expect(missing.result?.structuredContent?.adcp_error?.field).toBe('idempotency_key'); + expect(invalidKeylessLegacy.result?.structuredContent?.adcp_error).toMatchObject({ + code: 'INVALID_REQUEST', + field: 'buying_mode', + }); + + const invalidKeylessList = await callTenantTool(url, 32, 'list_products', { + account, + pagination: { max_results: 0 }, + }) as { result?: { structuredContent?: { adcp_error?: { code?: string; field?: string } } } }; + expect(invalidKeylessList.result?.structuredContent?.adcp_error).toMatchObject({ + code: 'INVALID_REQUEST', + field: 'pagination.max_results', + }); + + const malformedAccount = await callTenantTool(url, 33, 'list_products', { + account: 'not-an-account', + }) as { result?: { structuredContent?: { adcp_error?: { code?: string; field?: string } } } }; + expect(malformedAccount.result?.structuredContent?.adcp_error).toMatchObject({ + code: 'INVALID_REQUEST', + field: 'account', + }); + + const unsupportedAliasVersion = await callTenantTool(url, 34, 'list_products', { + account, + adcp_version: '99.0', + }) as { + result?: { + structuredContent?: { + adcp_error?: { code?: string; field?: string; details?: { supported_versions?: string[] } }; + }; + }; + }; + expect(unsupportedAliasVersion.result?.structuredContent?.adcp_error).toMatchObject({ + code: 'VERSION_UNSUPPORTED', + field: 'adcp_version', + details: { supported_versions: expect.any(Array) }, + }); const first = await callTenantTool(url, 4, 'get_products', payload) as { result?: { structuredContent?: { products?: unknown[]; replayed?: boolean } }; @@ -1554,7 +1622,114 @@ describe('tenant routing smoke', () => { expect(replay.result?.structuredContent?.products).toEqual(first.result?.structuredContent?.products); expect(replay.result?.structuredContent?.replayed).toBe(true); - const invalid = await callTenantTool(url, 6, 'get_products', { + const aliasReplay = await callTenantTool(url, 6, 'list_products', { + idempotency_key: payload.idempotency_key, + adcp_version: payload.adcp_version, + account, + }) as { result?: { structuredContent?: { adcp_version?: string; products?: unknown[]; replayed?: boolean } } }; + expect(aliasReplay.result?.structuredContent).not.toHaveProperty('adcp_error'); + expect(aliasReplay.result?.structuredContent?.adcp_version).toBe('3.2-beta.0'); + expect(aliasReplay.result?.structuredContent?.products).toEqual(first.result?.structuredContent?.products); + expect(aliasReplay.result?.structuredContent?.replayed).toBe(true); + + const taskKey = 'tenant-products-task-receipt-0001'; + const taskCall = async (id: number): Promise> => { + const response = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + authorization: 'Bearer test-token', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { + name: 'recommend_products', + arguments: { + idempotency_key: taskKey, + account, + brief: 'Reach sports fans', + }, + task: { ttl: 120000 }, + }, + }), + }); + return response.json() as Promise>; + }; + const taskFirst = await taskCall(62) as { result?: { task?: { taskId?: string; status?: string } } }; + const taskReplay = await taskCall(63) as { result?: { task?: { taskId?: string; status?: string } } }; + expect(taskFirst.result?.task).toMatchObject({ status: 'completed', taskId: expect.any(String) }); + expect(taskReplay.result?.task?.taskId).toBe(taskFirst.result?.task?.taskId); + + const taskGetResponse = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + authorization: 'Bearer test-token', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 64, + method: 'tasks/get', + params: { taskId: taskFirst.result?.task?.taskId, adcp_version: '3.2-beta.0' }, + }), + }); + const taskGet = await taskGetResponse.json() as { result?: { taskId?: string; status?: string } }; + expect(taskGet.result).toMatchObject({ taskId: taskFirst.result?.task?.taskId, status: 'completed' }); + + const taskListResponse = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + authorization: 'Bearer test-token', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 641, + method: 'tasks/list', + params: { adcp_version: '3.2-beta.0' }, + }), + }); + const taskList = await taskListResponse.json() as { result?: { tasks?: Array<{ taskId?: string }> } }; + expect(taskList.result?.tasks).toEqual(expect.arrayContaining([ + expect.objectContaining({ taskId: taskFirst.result?.task?.taskId }), + ])); + + const forbiddenListTask = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + authorization: 'Bearer test-token', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 65, + method: 'tools/call', + params: { + name: 'list_products', + arguments: { account }, + task: { ttl: 120000 }, + }, + }), + }); + const forbiddenListTaskBody = await forbiddenListTask.json() as { error?: { code?: number; message?: string } }; + expect(forbiddenListTaskBody.error?.message).toContain('does not support task augmentation'); + + const missingAliasKey = await callTenantTool(url, 61, 'recommend_products', { + account, + brief: 'Reach sports fans', + }) as { error?: { code?: number; data?: { field?: string } } }; + expect(missingAliasKey.error).toMatchObject({ + code: -32602, + data: { field: 'idempotency_key' }, + }); + + const invalid = await callTenantTool(url, 7, 'get_products', { ...payload, buying_mode: 'not-a-mode', }) as { result?: { structuredContent?: { adcp_error?: { code?: string; field?: string } } } }; @@ -1563,7 +1738,7 @@ describe('tenant routing smoke', () => { field: 'buying_mode', }); - const mixedFinalize = await callTenantTool(url, 7, 'get_products', { + const mixedFinalize = await callTenantTool(url, 8, 'get_products', { ...payload, buying_mode: 'refine', refine: [ @@ -1576,7 +1751,7 @@ describe('tenant routing smoke', () => { field: 'refine[1]', }); - const conflict = await callTenantTool(url, 8, 'get_products', { + const conflict = await callTenantTool(url, 9, 'get_products', { ...payload, buying_mode: 'brief', brief: 'different logical request', diff --git a/server/src/training-agent/v6-sales-platform.ts b/server/src/training-agent/v6-sales-platform.ts index 7d0b14e476..f359c5d618 100644 --- a/server/src/training-agent/v6-sales-platform.ts +++ b/server/src/training-agent/v6-sales-platform.ts @@ -410,6 +410,50 @@ const trainingSalesAccounts: AccountStore = { upsert: syncAccountsUpsert, }; +/** + * Resolve the trusted sales account and buyer-agent context for native MCP + * dispatchers that sit beside the SDK facade. Keeping this on the platform's + * actual resolvers prevents the split 3.2 tools from treating buyer input as + * an already-authorized account. + */ +export async function resolveTrainingSalesRequestContext( + input: Record, + auth: { + clientId?: string; + scopes?: string[]; + extra?: Record; + } | undefined, + storyboardCompat?: TrainingContext['storyboardCompat'], +): Promise { + const credential = auth?.extra?.credential; + const agent = await trainingBuyerAgentRegistry.resolve({ + ...(credential ? { credential: credential as never } : {}), + ...(auth?.extra && { extra: auth.extra }), + input, + }); + const account = await trainingSalesAccounts.resolve( + input.account as never, + { + authInfo: { + ...(auth?.clientId && { clientId: auth.clientId }), + ...(auth?.scopes && { scopes: auth.scopes }), + ...(credential ? { credential: credential as never } : {}), + ...(auth?.extra && { extra: auth.extra }), + }, + toolName: 'get_products', + ...(agent && { agent }), + input, + }, + ); + if (!account) throw new Error('Unable to resolve sales account'); + return buildTrainingCtx({ + account, + authInfo: { clientId: auth?.clientId }, + ...(agent && { agent }), + input, + }, storyboardCompat); +} + /** * Temporary raw-wire compatibility adapters for creative identity surfaces. * diff --git a/server/tests/integration/training-agent-webhooks.test.ts b/server/tests/integration/training-agent-webhooks.test.ts index a36d935004..d920bad815 100644 --- a/server/tests/integration/training-agent-webhooks.test.ts +++ b/server/tests/integration/training-agent-webhooks.test.ts @@ -180,6 +180,70 @@ describe('Training Agent webhook emission', () => { } }, 20000); + it('emits one canonical get_products webhook for a replayed 3.2 split call', async () => { + const deliveries: CapturedDelivery[] = []; + let srv: http.Server | undefined; + try { + let resolveFirstDelivery: (() => void) | undefined; + const firstDelivery = new Promise(resolve => { resolveFirstDelivery = resolve; }); + srv = await startReceiver((delivery, res) => { + deliveries.push(delivery); + res.writeHead(200); + res.end(); + resolveFirstDelivery?.(); + }); + const addr = srv.address() as AddressInfo; + const webhookUrl = `http://127.0.0.1:${addr.port}/hook/get_products`; + const logicalRequest = { + idempotency_key: `split-products-${randomUUID()}`, + account: { + brand: { domain: 'split-webhook.example' }, + operator: 'split-webhook.example', + }, + brief: 'Reach sports fans', + push_notification_config: { + url: webhookUrl, + operation_id: 'op_split_products', + }, + }; + const call = () => request(app) + .post('/api/training-agent/sales/mcp') + .set('Authorization', AUTH) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ + jsonrpc: '2.0', + id: randomUUID(), + method: 'tools/call', + params: { name: 'recommend_products', arguments: logicalRequest }, + }); + + const first = await call(); + const replay = await call(); + expect(structuredToolResult(first)).not.toHaveProperty('adcp_error'); + expect(structuredToolResult(replay)).toMatchObject({ replayed: true }); + await Promise.race([ + firstDelivery, + new Promise((_, reject) => setTimeout(() => reject(new Error('webhook never arrived')), 10_000)), + ]); + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(deliveries).toHaveLength(1); + const body = JSON.parse(deliveries[0].body) as Record; + expect(body).toMatchObject({ + operation_id: 'op_split_products', + task_type: 'get_products', + protocol: 'media-buy', + status: 'completed', + }); + } finally { + if (srv) { + srv.closeAllConnections?.(); + await new Promise(resolve => srv!.close(() => resolve())); + } + } + }, 20000); + it('falls back to task_id when the buyer omits webhook operation_id', async () => { const deliveries: CapturedDelivery[] = []; let srv: http.Server | undefined; diff --git a/server/tests/unit/idempotency.test.ts b/server/tests/unit/idempotency.test.ts index 170a6c7714..8f4feb85d9 100644 --- a/server/tests/unit/idempotency.test.ts +++ b/server/tests/unit/idempotency.test.ts @@ -72,7 +72,7 @@ describe('idempotency facade', () => { it('covers specific mutating tools explicitly', () => { for (const name of [ 'create_media_buy', 'update_media_buy', 'sync_audiences', - 'get_products', + 'recommend_products', 'refine_proposal', 'finalize_proposals', 'si_initiate_session', 'si_send_message', 'acquire_rights', 'update_rights', 'creative_approval', ]) { @@ -83,6 +83,7 @@ describe('idempotency facade', () => { it('excludes read-only and discovery tools', () => { for (const name of [ 'get_media_buys', + 'get_products', 'list_products', 'get_adcp_capabilities', 'check_governance', 'si_terminate_session', diff --git a/server/tests/unit/idempotent-task-postgres-recovery.test.ts b/server/tests/unit/idempotent-task-postgres-recovery.test.ts new file mode 100644 index 0000000000..82e3638678 --- /dev/null +++ b/server/tests/unit/idempotent-task-postgres-recovery.test.ts @@ -0,0 +1,97 @@ +import { PostgresTaskStore, type PgQueryable } from '@adcp/sdk'; +import { describe, expect, it } from 'vitest'; +import { + createOrReuseIdempotentTask, + getIdempotentTask, + idempotentTaskId, +} from '../../src/training-agent/task-handlers.js'; + +type TaskRow = Record & { + task_id: string; + status: string; +}; + +function taskRow(taskId: string, status: string): TaskRow { + const now = new Date().toISOString(); + return { + task_id: taskId, + status, + ttl: 86_400_000, + poll_interval: 1000, + status_message: null, + request_id: '0', + request: {}, + result: status === 'completed' ? { structuredContent: { proposals: [] } } : null, + created_at: now, + last_updated_at: now, + expires_at: new Date(Date.now() + 86_400_000).toISOString(), + }; +} + +class ExpiryAwareTaskDb implements PgQueryable { + readonly rows = new Map(); + readonly expiredIds = new Set(); + readonly insertedIds: string[] = []; + + async query(text: string, values: unknown[] = []): Promise<{ + rows: Record[]; + rowCount: number; + }> { + const taskId = String(values[0]); + if (text.includes('SELECT *') && text.includes('WHERE task_id = $1')) { + const row = this.rows.get(taskId); + return { + rows: row && !this.expiredIds.has(taskId) ? [row] : [], + rowCount: row && !this.expiredIds.has(taskId) ? 1 : 0, + }; + } + if (text.includes('INSERT INTO')) { + if (this.rows.has(taskId)) { + throw Object.assign(new Error('duplicate key value violates unique constraint'), { code: '23505' }); + } + const row = taskRow(taskId, 'working'); + this.rows.set(taskId, row); + this.insertedIds.push(taskId); + return { rows: [row], rowCount: 1 }; + } + throw new Error(`Unexpected task-store query: ${text}`); + } +} + +const request = { + method: 'tools/call', + params: { name: 'get_products', arguments: { buying_mode: 'brief' } }, +}; + +describe('Postgres idempotent task receipt generations', () => { + it('finds a live replacement after an expired generation-zero hole', async () => { + const naturalKey = 'principal\0get_products\0success:key:hash'; + const gen0 = idempotentTaskId(naturalKey, 0); + const gen1 = idempotentTaskId(naturalKey, 1); + const db = new ExpiryAwareTaskDb(); + db.rows.set(gen0, taskRow(gen0, 'completed')); + db.expiredIds.add(gen0); + db.rows.set(gen1, taskRow(gen1, 'completed')); + const store = new PostgresTaskStore(db); + + await expect(getIdempotentTask(store, naturalKey)) + .resolves.toMatchObject({ taskId: gen1, status: 'completed' }); + await expect(createOrReuseIdempotentTask(store, naturalKey, 60_000, request)) + .resolves.toMatchObject({ taskId: gen1, status: 'completed' }); + expect(db.insertedIds).toEqual([]); + }); + + it('skips an expired uncleaned primary key when creating a replacement', async () => { + const naturalKey = 'principal\0get_products\0success:other-key:other-hash'; + const gen0 = idempotentTaskId(naturalKey, 0); + const gen1 = idempotentTaskId(naturalKey, 1); + const db = new ExpiryAwareTaskDb(); + db.rows.set(gen0, taskRow(gen0, 'completed')); + db.expiredIds.add(gen0); + const store = new PostgresTaskStore(db); + + await expect(createOrReuseIdempotentTask(store, naturalKey, 60_000, request)) + .resolves.toMatchObject({ taskId: gen1, status: 'working' }); + expect(db.insertedIds).toEqual([gen1]); + }); +}); diff --git a/server/tests/unit/product-discovery-schema-parity.test.ts b/server/tests/unit/product-discovery-schema-parity.test.ts new file mode 100644 index 0000000000..1f1f4d509d --- /dev/null +++ b/server/tests/unit/product-discovery-schema-parity.test.ts @@ -0,0 +1,107 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import Ajv from 'ajv'; +import { describe, expect, it } from 'vitest'; +import { productDiscoveryAliasToolDefinitions } from '../../src/training-agent/task-handlers.js'; + +type JsonSchema = Record; + +function sourceSchema(name: string): JsonSchema { + return JSON.parse(readFileSync( + join(process.cwd(), `static/schemas/source/media-buy/${name}.json`), + 'utf8', + )) as JsonSchema; +} + +function forbiddenFields(schema: JsonSchema): string[] { + return (schema.allOf ?? []).flatMap((entry: JsonSchema) => ( + entry.not?.anyOf ?? [] + ).flatMap((clause: JsonSchema) => clause.required ?? [])).sort(); +} + +function refinementBranches(schema: JsonSchema): unknown { + return schema.items.oneOf.map((branch: JsonSchema) => ({ + scope: branch.properties.scope.const, + required: branch.required, + additionalProperties: branch.additionalProperties, + action: branch.properties.action?.enum, + fieldMinimums: Object.fromEntries(Object.entries(branch.properties) + .flatMap(([name, value]) => (value as JsonSchema).minLength === undefined + ? [] + : [[name, (value as JsonSchema).minLength]])), + })); +} + +function resolveLocalRef(root: JsonSchema, value: JsonSchema): JsonSchema { + const ref = value.$ref as string | undefined; + if (!ref?.startsWith('#/')) return value; + return ref.slice(2).split('/').reduce((current: JsonSchema, encoded: string) => ( + current[encoded.replaceAll('~1', '/').replaceAll('~0', '~')] as JsonSchema + ), root); +} + +describe('product discovery MCP schema parity', () => { + it('keeps runtime discovery constraints aligned with source schemas', () => { + const tools = new Map(productDiscoveryAliasToolDefinitions().map(tool => [tool.name, tool.inputSchema as JsonSchema])); + for (const [toolName, fileName] of [ + ['list_products', 'list-products-request'], + ['recommend_products', 'recommend-products-request'], + ['refine_proposal', 'refine-proposal-request'], + ['finalize_proposals', 'finalize-proposals-request'], + ] as const) { + const runtime = tools.get(toolName)!; + const source = sourceSchema(fileName); + expect(runtime.required ?? []).toEqual(source.required ?? []); + expect(runtime.dependencies ?? {}).toEqual(source.dependencies ?? {}); + expect(forbiddenFields(runtime)).toEqual(forbiddenFields(source)); + expect(runtime.properties.idempotency_key).toMatchObject({ + minLength: source.properties.idempotency_key.minLength, + maxLength: source.properties.idempotency_key.maxLength, + pattern: source.properties.idempotency_key.pattern, + }); + } + + const recommend = tools.get('recommend_products')!; + expect(recommend.properties.brief.minLength) + .toBe(sourceSchema('recommend-products-request').properties.brief.minLength); + + const list = tools.get('list_products')!; + expect(list.allOf).toEqual(expect.arrayContaining([ + expect.objectContaining({ + if: { required: ['if_pricing_version'] }, + then: { required: ['if_wholesale_feed_version'] }, + }), + ])); + + const refineTool = tools.get('refine_proposal')!; + const runtimeRefinement = resolveLocalRef(refineTool, refineTool.properties.refine.allOf[0]); + const sourceRefinement = sourceSchema('product-refinement'); + expect(runtimeRefinement).toMatchObject({ type: sourceRefinement.type, minItems: sourceRefinement.minItems }); + expect(refinementBranches(runtimeRefinement)).toEqual(refinementBranches(sourceRefinement)); + + // Repository-local refs must be bundled because MCP consumers do not have + // an AdCP schema registry attached to tools/list. + for (const runtime of tools.values()) { + expect(JSON.stringify(runtime)).not.toContain('"$ref":"/schemas/'); + } + expect(resolveLocalRef(list, list.properties.brand)).toMatchObject({ required: ['domain'], additionalProperties: false }); + expect(resolveLocalRef(list, list.properties.catalog)).toMatchObject({ required: ['type'] }); + expect(resolveLocalRef(list, list.properties.fields)).toMatchObject({ + minItems: 1, + uniqueItems: true, + items: { enum: expect.arrayContaining(['product_id', 'format_options', 'pricing_options']) }, + }); + expect(resolveLocalRef(list, list.properties.property_list)).toMatchObject({ + required: ['agent_url', 'list_id'], + additionalProperties: false, + }); + expect(resolveLocalRef(list, list.properties.pagination)).toMatchObject({ additionalProperties: false }); + }); + + it('bundles each tools/list input schema as a valid standalone document', () => { + const ajv = new Ajv({ strict: false, validateFormats: false }); + for (const tool of productDiscoveryAliasToolDefinitions()) { + expect(() => ajv.compile(tool.inputSchema), tool.name).not.toThrow(); + } + }); +}); diff --git a/server/tests/unit/training-agent-idempotency.test.ts b/server/tests/unit/training-agent-idempotency.test.ts index 9718c74027..e6b28c0e6e 100644 --- a/server/tests/unit/training-agent-idempotency.test.ts +++ b/server/tests/unit/training-agent-idempotency.test.ts @@ -27,6 +27,7 @@ import { getIdempotencyStore, } from '../../src/training-agent/idempotency.js'; import type { TrainingContext } from '../../src/training-agent/types.js'; +import { getTrainingTaskStore } from '../../src/training-agent/mcp-task-store.js'; const CTX: TrainingContext = { mode: 'open', principal: 'test-principal' }; @@ -140,16 +141,49 @@ describe('training agent idempotency middleware', () => { expect((parsed as any).adcp_error?.code).toBe('INVALID_REQUEST'); }); - it('rejects get_products with no idempotency_key before any polymorphic arm runs', async () => { + it('keeps keyless get_products valid throughout 3.x', async () => { const { parsed, isError } = await call(server, 'get_products', { adcp_version: '3.1-rc.15', buying_mode: 'wholesale', account: ACCOUNT, brand: BRAND, }); - expect(isError).toBe(true); - expect((parsed as any).adcp_error?.code).toBe('INVALID_REQUEST'); - expect((parsed as any).adcp_error?.field).toBe('idempotency_key'); + expect(isError).toBeFalsy(); + expect((parsed.products as unknown[])?.length).toBeGreaterThan(0); + }); + + it('still validates keyless legacy and list product reads', async () => { + const legacy = await call(server, 'get_products', { + buying_mode: 'not-a-mode', + account: ACCOUNT, + }); + expect(legacy.isError).toBe(true); + expect((legacy.parsed as any).adcp_error).toMatchObject({ + code: 'INVALID_REQUEST', + field: 'buying_mode', + }); + + const list = await call(server, 'list_products', { + account: ACCOUNT, + pagination: { max_results: 0 }, + }); + expect(list.isError).toBe(true); + expect((list.parsed as any).adcp_error).toMatchObject({ + code: 'INVALID_REQUEST', + field: 'pagination.max_results', + }); + }); + + it('accepts and ignores callback configuration on synchronous list_products', async () => { + const result = await call(server, 'list_products', { + account: ACCOUNT, + push_notification_config: { + url: 'https://callbacks.example/list-products', + operation_id: 'list-products-wrapper-envelope', + }, + }); + expect(result.isError).toBeFalsy(); + expect(result.parsed.products).toEqual(expect.any(Array)); }); it('adapts only served-3.0 missing-key get_products requests to safe deterministic replay', async () => { @@ -341,6 +375,128 @@ describe('training agent idempotency middleware', () => { expect((conflict.parsed as any).adcp_error?.code).toBe('IDEMPOTENCY_CONFLICT'); }); + it('shares replay identity between legacy wholesale discovery and list_products', async () => { + const key = `products-list-alias-${randomUUID()}`; + const shared = { + idempotency_key: key, + adcp_version: '3.2-beta.0', + account: ACCOUNT, + brand: BRAND, + }; + + const first = await call(server, 'get_products', { + ...shared, + buying_mode: 'wholesale', + }); + expect(first.isError).toBeFalsy(); + + const replay = await call(server, 'list_products', shared); + expect(replay.isError).toBeFalsy(); + expect(replay.parsed.replayed).toBe(true); + expect(replay.parsed.products).toEqual(first.parsed.products); + + const conflict = await call(server, 'list_products', { + ...shared, + fields: ['product_id'], + }); + expect(conflict.isError).toBe(true); + expect((conflict.parsed as any).adcp_error?.code).toBe('IDEMPOTENCY_CONFLICT'); + }); + + it('treats caller-supplied version pins as part of product request identity', async () => { + const key = `products-version-pin-${randomUUID()}`; + const first = await call(server, 'get_products', { + idempotency_key: key, + adcp_version: '3.1-rc.15', + buying_mode: 'wholesale', + account: ACCOUNT, + }); + expect(first.isError).toBeFalsy(); + + const conflict = await call(server, 'get_products', { + idempotency_key: key, + adcp_version: '3.2-beta.0', + buying_mode: 'wholesale', + account: ACCOUNT, + }); + expect(conflict.isError).toBe(true); + expect((conflict.parsed as any).adcp_error?.code).toBe('IDEMPOTENCY_CONFLICT'); + }); + + it('does not replay unpinned product aliases across different effective releases', async () => { + const shared = { + idempotency_key: `products-effective-version-${randomUUID()}`, + account: ACCOUNT, + }; + const legacy = await call(server, 'get_products', { + ...shared, + buying_mode: 'wholesale', + }); + expect(legacy.isError).toBeFalsy(); + + const split = await call(server, 'list_products', shared); + expect(split.isError).toBe(true); + expect((split.parsed as any).adcp_error?.code).toBe('IDEMPOTENCY_CONFLICT'); + }); + + it('reuses one task receipt when a brief retry switches to recommend_products', async () => { + const key = `products-recommend-task-alias-${randomUUID()}`; + const shared = { + idempotency_key: key, + adcp_version: '3.2-beta.0', + account: ACCOUNT, + brand: BRAND, + brief: 'cross-channel news video and display', + }; + + const first = await callAsTask(server, 'get_products', { + ...shared, + buying_mode: 'brief', + }); + const firstTaskId = (first.parsed.task as { taskId?: string })?.taskId; + expect(firstTaskId).toBeTruthy(); + + const replay = await callAsTask(server, 'recommend_products', shared); + expect((replay.parsed.task as { taskId?: string })?.taskId).toBe(firstTaskId); + expect(replay.parsed.replayed).toBe(true); + }); + + it('projects one cached product result across inline then task execution modes', async () => { + const shared = { + idempotency_key: `products-inline-task-${randomUUID()}`, + adcp_version: '3.2-beta.0', + account: ACCOUNT, + brief: 'cross-channel sports', + }; + const inline = await call(server, 'recommend_products', shared); + expect(inline.isError).toBeFalsy(); + + const task = await callAsTask(server, 'recommend_products', shared); + expect(task.isError).toBeFalsy(); + expect(task.parsed).toMatchObject({ + replayed: true, + task: { taskId: expect.any(String), status: 'completed' }, + }); + const result = await taskResult(server, (task.parsed.task as { taskId: string }).taskId); + expect(result.structuredContent).toMatchObject({ products: inline.parsed.products }); + }); + + it('projects one cached product result across task then inline execution modes', async () => { + const shared = { + idempotency_key: `products-task-inline-${randomUUID()}`, + adcp_version: '3.2-beta.0', + account: ACCOUNT, + brief: 'cross-channel news', + }; + const task = await callAsTask(server, 'recommend_products', shared); + expect(task.parsed.task).toMatchObject({ taskId: expect.any(String), status: 'completed' }); + + const inline = await call(server, 'recommend_products', shared); + expect(inline.isError).toBeFalsy(); + expect(inline.parsed).toMatchObject({ replayed: true, products: expect.any(Array) }); + expect(inline.parsed).not.toHaveProperty('task'); + }); + it('validates the complete get_products payload before consulting the cache', async () => { const key = `products-schema-first-${randomUUID()}`; await call(server, 'get_products', { @@ -466,6 +622,53 @@ describe('training agent idempotency middleware', () => { expect(replay.parsed.replayed).toBe(true); }); + it('does not rerun or cache success for a cancelled orphan task', async () => { + const key = `products-task-cancelled-orphan-${randomUUID()}`; + const payload = { + idempotency_key: key, + brief: 'A retry-safe campaign recommendation', + account: ACCOUNT, + }; + const taskStore = getTrainingTaskStore(); + const storeFailure = vi.spyOn(taskStore, 'storeTaskResult') + .mockRejectedValueOnce(new Error('injected task-result persistence failure')); + + await expect(callAsTask(server, 'recommend_products', payload)) + .rejects.toThrow('injected task-result persistence failure'); + storeFailure.mockRestore(); + + const orphan = (await taskStore.listTasks()).tasks.find(task => task.status === 'working'); + expect(orphan?.taskId).toBeTruthy(); + await taskStore.updateTaskStatus(orphan!.taskId, 'cancelled', 'cancelled by buyer'); + + const cancelledRetry = await callAsTask(server, 'recommend_products', payload); + expect(cancelledRetry.parsed).toMatchObject({ + replayed: true, + task: { taskId: orphan!.taskId, status: 'cancelled' }, + }); + + // The cancelled receipt released the cache claim instead of publishing + // a hidden success. A later inline request therefore executes normally. + const inlineRetry = await call(server, 'recommend_products', payload); + expect(inlineRetry.isError).not.toBe(true); + expect(inlineRetry.parsed.replayed).toBeUndefined(); + + const taskReplay = await callAsTask(server, 'recommend_products', payload); + const replacementTask = taskReplay.parsed.task as { taskId: string; status: string }; + expect(replacementTask).toMatchObject({ status: 'completed' }); + expect(replacementTask.taskId).not.toBe(orphan!.taskId); + const replacementResult = await taskResult(server, replacementTask.taskId); + expect(replacementResult.isError).not.toBe(true); + expect((replacementResult.structuredContent as { proposals?: unknown[] })?.proposals?.length) + .toBeGreaterThan(0); + + const stableReplay = await callAsTask(server, 'recommend_products', payload); + expect(stableReplay.parsed).toMatchObject({ + replayed: true, + task: { taskId: replacementTask.taskId, status: 'completed' }, + }); + }); + it('does not collapse identical non-idempotency-protected task calls', async () => { const first = await callAsTask(server, 'get_signals', {}); const second = await callAsTask(server, 'get_signals', {}); @@ -531,14 +734,6 @@ describe('training agent idempotency middleware', () => { account, }); - const missing = await call(server, 'get_products', { - buying_mode: 'refine', - account, - refine: [{ scope: 'proposal', action: 'finalize', proposal_id: 'pinnacle_cross_channel' }], - }); - expect(missing.isError).toBe(true); - expect((missing.parsed as any).adcp_error?.field).toBe('idempotency_key'); - const key = `products-finalize-${randomUUID()}`; const payload = { idempotency_key: key, @@ -882,14 +1077,13 @@ describe('training agent idempotency middleware', () => { }); describe('in-process Addie dispatch', () => { - it('enforces and replays get_products idempotency instead of bypassing the middleware', async () => { + it('honors optional get_products idempotency instead of bypassing the middleware', async () => { const ctx: TrainingContext = { mode: 'training', principal: 'addie-test' }; const missing = await executeTrainingAgentTool('get_products', { buying_mode: 'wholesale', account: ACCOUNT, }, ctx); - expect(missing.success).toBe(false); - expect(missing.error).toContain('idempotency_key'); + expect(missing.success).toBe(true); const payload = { idempotency_key: `addie-products-${randomUUID()}`, diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index 0e79326702..0a5b88ebba 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -1368,6 +1368,10 @@ describe('createTrainingAgentServer', () => { const toolNames = tools.map(t => t.name); expect(toolNames).toContain('get_products'); + expect(toolNames).toContain('list_products'); + expect(toolNames).toContain('recommend_products'); + expect(toolNames).toContain('refine_proposal'); + expect(toolNames).toContain('finalize_proposals'); expect(toolNames).toContain('list_creative_formats'); expect(toolNames).toContain('create_media_buy'); expect(toolNames).toContain('get_media_buys'); @@ -1418,7 +1422,7 @@ describe('createTrainingAgentServer', () => { expect(toolNames).toContain('update_collection_list'); expect(toolNames).toContain('list_collection_lists'); expect(toolNames).toContain('delete_collection_list'); - expect(toolNames).toHaveLength(51); + expect(toolNames).toHaveLength(55); const validateInput = tools.find(t => t.name === 'validate_input'); expect(validateInput?.inputSchema?.properties?.targets?.maxItems).toBe(50); @@ -12316,6 +12320,7 @@ describe('MCP Tasks protocol', () => { const response = await simulateCallToolAsTask(server, 'get_products', { adcp_version: '3.1', adcp_major_version: 3, + idempotency_key: 'task-products-receipt-0001', buying_mode: 'wholesale', }); @@ -12336,6 +12341,7 @@ describe('MCP Tasks protocol', () => { it('overrides requested TTL for successful idempotency recovery receipts', async () => { const server = createTrainingAgentServer(DEFAULT_CTX); const response = await simulateCallToolAsTask(server, 'get_products', { + idempotency_key: 'task-products-receipt-0002', buying_mode: 'wholesale', }, { ttl: 120000 }); @@ -12504,6 +12510,7 @@ describe('MCP Tasks protocol', () => { try { const server = createTrainingAgentServer(DEFAULT_CTX); const response = await simulateCallToolAsTask(server, 'get_products', { + idempotency_key: 'task-products-receipt-0003', buying_mode: 'wholesale', }, { ttl: 1 }); // caller suggests a 1ms TTL diff --git a/static/compliance/source/universal/read-tool-idempotency.yaml b/static/compliance/source/universal/read-tool-idempotency.yaml index 574ead463d..a83942fbf0 100644 --- a/static/compliance/source/universal/read-tool-idempotency.yaml +++ b/static/compliance/source/universal/read-tool-idempotency.yaml @@ -3,7 +3,7 @@ version: "1.0.0" introduced_in: "3.1" title: "Read-tool idempotency_key tolerance" category: idempotency -summary: "Validates that read-only AdCP tasks accept an optional idempotency_key without strict wrapper rejection, while AdCP 4.0 get_products rejects an omitted key." +summary: "Validates that read-only AdCP tasks and the 3.x get_products compatibility facade accept an optional idempotency_key without strict wrapper rejection." track: core required_tools: [] # protocol-level baseline; read-tool probes gate themselves per step @@ -24,8 +24,8 @@ narrative: | The final phase intentionally omits `idempotency_key` on `get_adcp_capabilities`. Sellers MUST accept this bootstrap request without a key because it is how buyers discover the seller's idempotency declaration. - AdCP 4.0 separately requires the key on polymorphic `get_products`, covered by - the preceding negative vector. + AdCP 3.2 split product-discovery mutations require the key in their own + request schemas; legacy `get_products` remains key-optional throughout 3.x. agent: interaction_model: "*" # applies to every agent regardless of protocol or specialism @@ -269,70 +269,6 @@ phases: value: "read_tool_idempotency--list_creatives_with_key" description: "Context correlation_id returned unchanged" - - id: get_products_missing_key_4_0 - title: "AdCP 4.0 get_products requires idempotency_key" - narrative: | - `get_products` is polymorphic: a call may create a task or finalize a - proposal even when another call completes as a read. Its AdCP 4.0 schema - therefore requires `idempotency_key` before execution-arm selection. - - steps: - - id: get_products_without_idempotency_key_4_0 - title: "Missing get_products idempotency_key returns INVALID_REQUEST" - narrative: | - The request intentionally omits `idempotency_key` and sets - `omit_idempotency_key: true` so future runners that auto-inject keys - preserve the missing-key vector. The seller must reject before - creating a task, returning products, or finalizing state. - task: get_products - requires_tool: get_products - schema_ref: "media-buy/get-products-request.json" - response_schema_ref: "media-buy/get-products-response.json" - doc_ref: "/media-buy/task-reference/get_products" - comply_scenario: read_tool_idempotency - omit_idempotency_key: true - expect_error: true - negative_path: schema_invalid - stateful: false - expected: | - Reject before handler execution with: - - Error code: INVALID_REQUEST or VALIDATION_ERROR - - No task_id, products, or proposals payload - - sample_request: - buying_mode: "brief" - brief: "Show available advertising products for an outdoor lifestyle campaign." - account: - brand: - domain: "acmeoutdoor.example" - operator: "pinnacle-agency.example" - context: - correlation_id: "read_tool_idempotency--get_products_missing_key_4_0" - ext: - adcp: - storyboard: "read_tool_idempotency" - probe: "get_products_missing_key_4_0" - validations: - - check: error_code - allowed_values: ["INVALID_REQUEST", "VALIDATION_ERROR"] - description: "Missing get_products idempotency_key is rejected" - - check: field_present - path: "context" - description: "Response echoes back the context object" - - check: field_value - path: "context.correlation_id" - value: "read_tool_idempotency--get_products_missing_key_4_0" - description: "Context correlation_id returned unchanged" - - check: field_absent - path: "task_id" - description: "Validation failure does not allocate an async task" - - check: field_absent - path: "products" - description: "Validation failure does not execute product discovery" - - check: field_absent - path: "proposals" - description: "Validation failure does not return or finalize proposals" - - id: capabilities_bootstrap_without_key title: "Capabilities bootstrap accepts an omitted idempotency_key" narrative: | diff --git a/static/schemas/source/core/account-authorization.json b/static/schemas/source/core/account-authorization.json index 0e2e8c0a5f..4b3ddc8686 100644 --- a/static/schemas/source/core/account-authorization.json +++ b/static/schemas/source/core/account-authorization.json @@ -7,7 +7,7 @@ "properties": { "allowed_tasks": { "type": "array", - "description": "Canonical snake_case task names the caller may invoke against this account (e.g., get_media_buys, update_media_buy, create_media_buy, sync_creatives). Absence of a task from this list MUST be interpreted as 'not permitted' — invoking an absent task MUST return SCOPE_INSUFFICIENT. This list reflects the caller's grant, not the seller's universal capability surface (for that, see get_adcp_capabilities). A seller may grant narrower subsets to different callers on the same account.", + "description": "Canonical snake_case task names the caller may invoke against this account (e.g., get_media_buys, update_media_buy, create_media_buy, sync_creatives). Absence of a task from this list MUST be interpreted as 'not permitted' — invoking an absent task MUST return SCOPE_INSUFFICIENT. This list reflects the caller's grant, not the seller's universal capability surface (for that, see get_adcp_capabilities). A seller may grant narrower subsets to different callers on the same account. AdCP 3.2 product-discovery compatibility: get_products is the authorization name for the full operation family, including every advertised split alias. Sellers normalize aliases before authorization; alias-only grants are not defined during the 3.x compatibility window.", "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" @@ -16,7 +16,7 @@ }, "field_scopes": { "type": "object", - "description": "Optional per-task allowlist of request fields the caller may set. Keys are task names (which MUST also appear in allowed_tasks). Values are arrays of top-level request-field paths permitted for that task. When a task appears in field_scopes, requests to that task with any field outside the allowlist MUST be rejected with FIELD_NOT_PERMITTED. Implicit framing fields are always permitted and do NOT need to appear in the allowlist — they identify the resource or shape the call rather than mutating business state. The list is non-exhaustive but covers the common cases: typed entity references (`account`, `media_buy_id`, `package_id`, `creative_id`, `signal_id`, `format_id`, `proposal_id`, `plan_id`, `session_id`), concurrency/idempotency (`revision`, `idempotency_key`), buyer-side correlation (`buyer_ref`, `po_number`), mode flags (`dry_run`), pagination (`pagination`, `cursor`, `max_results`), and envelope fields (`context`, `ext`, `adcp_major_version`, `push_notification_config` — transport-level async receipt, not business state). Any other typed entity-id parameter or query-shaping field on a read task SHOULD be treated as framing and not require listing. Tasks absent from field_scopes have no field-level restriction beyond what the task schema already enforces. An entry with an empty array means 'framing fields only, no business fields' — semantically distinct from the task being absent from field_scopes.", + "description": "Optional per-task allowlist of request fields the caller may set. Keys are task names (which MUST also appear in allowed_tasks). Values are arrays of top-level request-field paths permitted for that task. When a task appears in field_scopes, requests to that task with any field outside the allowlist MUST be rejected with FIELD_NOT_PERMITTED. AdCP 3.2 product-discovery aliases are normalized and checked against the get_products field_scopes entry; alias-specific entries are not defined during the 3.x compatibility window. Implicit framing fields are always permitted and do NOT need to appear in the allowlist — they identify the resource or shape the call rather than mutating business state. The list is non-exhaustive but covers the common cases: typed entity references (`account`, `media_buy_id`, `package_id`, `creative_id`, `signal_id`, `format_id`, `proposal_id`, `plan_id`, `session_id`), concurrency/idempotency (`revision`, `idempotency_key`), buyer-side correlation (`buyer_ref`, `po_number`), mode flags (`dry_run`), pagination (`pagination`, `cursor`, `max_results`), and envelope fields (`context`, `ext`, `adcp_major_version`, `push_notification_config` — transport-level async receipt, not business state). Any other typed entity-id parameter or query-shaping field on a read task SHOULD be treated as framing and not require listing. Tasks absent from field_scopes have no field-level restriction beyond what the task schema already enforces. An entry with an empty array means 'framing fields only, no business fields' — semantically distinct from the task being absent from field_scopes.", "additionalProperties": { "type": "array", "items": { @@ -44,7 +44,7 @@ }, "read_only": { "type": "boolean", - "description": "Convenience flag. When true, the caller is permitted only non-mutating tasks. Sellers MUST reject any mutation from a read-only caller with READ_ONLY_SCOPE. Sellers MAY omit this field; omission is equivalent to `false`. Callers MUST NOT infer read-only from `allowed_tasks` alone — the seller MUST set this explicitly when it applies.", + "description": "Convenience flag. When true, the caller is permitted only non-mutating tasks. Sellers MUST reject any mutation from a read-only caller with READ_ONLY_SCOPE. For the AdCP 3.2 product split, read-only permits list_products but rejects recommend_products, refine_proposal, and finalize_proposals; a legacy get_products call is permitted only when the seller can guarantee the selected arm is a synchronous side-effect-free read. Sellers MAY omit this field; omission is equivalent to `false`. Callers MUST NOT infer read-only from `allowed_tasks` alone — the seller MUST set this explicitly when it applies.", "default": false } }, diff --git a/static/schemas/source/core/x-entity-types.json b/static/schemas/source/core/x-entity-types.json index 060d825f0a..de0a415b4f 100644 --- a/static/schemas/source/core/x-entity-types.json +++ b/static/schemas/source/core/x-entity-types.json @@ -13,6 +13,7 @@ "media_buy", "package", "product", + "proposal", "placement", "product_pricing_option", "vendor_pricing_option", @@ -56,6 +57,7 @@ "media_buy": "A media buy / campaign. `media_buy_id` across media-buy/* schemas.", "package": "A line item within a media buy. `package_id` across media-buy/* schemas.", "product": "A seller inventory product. `product_id` across media-buy/* and core/product.json.", + "proposal": "A seller-issued draft or committed product proposal. `proposal_id` is returned by product recommendation/refinement flows and consumed by refine_proposal, finalize_proposals, and create_media_buy. Scoped to the issuing seller and account.", "placement": "A public ad placement within a publisher namespace. Identified by the composite of `publisher_domain` plus `placement_id` in core/placement-ref.json and core/placement.json; legacy single-publisher contexts may omit publisher_domain but the identifier is still placement-scoped, not product-scoped.", "product_pricing_option": "A pricing tier on a seller's inventory product (CPM / CPC / CPCV / etc). `pricing_option_id` inside `core/package.json` and `media-buy/package-request.json`. Scoped to the seller's product rate card — not interchangeable with `vendor_pricing_option`.", "vendor_pricing_option": "A pricing tier offered by a vendor agent (rights agent, signals agent, creative agent, governance agent) for its own services. `pricing_option_id` via `core/vendor-pricing-option.json`, also surfaced in `brand/acquire-rights-*`, `signals/activate-signal-request`, `media-buy/build-creative-response`, and `creative/get-creative-features-response`. Scoped to the issuing agent; not interchangeable with `product_pricing_option`.", diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index 6caa8b53d6..6780e6beef 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -1308,11 +1308,53 @@ "get-products": { "request": { "$ref": "/schemas/media-buy/get-products-request.json", - "description": "Request parameters for discovering available advertising products" + "deprecated": true, + "description": "AdCP 3.x compatibility request. New 3.2 callers use list_products, recommend_products, refine_proposal, or finalize_proposals." }, "response": { "$ref": "/schemas/media-buy/get-products-response.json", - "description": "Response payload for get_products task" + "deprecated": true, + "description": "AdCP 3.x compatibility response for get_products" + } + }, + "list-products": { + "request": { + "$ref": "/schemas/media-buy/list-products-request.json", + "description": "Request parameters for synchronous wholesale product-feed reads" + }, + "response": { + "$ref": "/schemas/media-buy/list-products-response.json", + "description": "Response payload for list_products" + } + }, + "recommend-products": { + "request": { + "$ref": "/schemas/media-buy/recommend-products-request.json", + "description": "Request parameters for curated brief-based product recommendations" + }, + "response": { + "$ref": "/schemas/media-buy/recommend-products-response.json", + "description": "Response payload for recommend_products" + } + }, + "refine-proposal": { + "request": { + "$ref": "/schemas/media-buy/refine-proposal-request.json", + "description": "Request parameters for changing one draft proposal" + }, + "response": { + "$ref": "/schemas/media-buy/refine-proposal-response.json", + "description": "Response payload for refine_proposal" + } + }, + "finalize-proposals": { + "request": { + "$ref": "/schemas/media-buy/finalize-proposals-request.json", + "description": "Request parameters for atomically committing proposal pricing and inventory holds" + }, + "response": { + "$ref": "/schemas/media-buy/finalize-proposals-response.json", + "description": "Response payload for finalize_proposals" } }, "list-creative-formats": { diff --git a/static/schemas/source/manifest.schema.json b/static/schemas/source/manifest.schema.json index 317d2ba90c..1f61255340 100644 --- a/static/schemas/source/manifest.schema.json +++ b/static/schemas/source/manifest.schema.json @@ -52,7 +52,16 @@ }, "mutating": { "type": "boolean", - "description": "True if invoking this tool can change server-side state. Mutating tools MUST declare an idempotency_key on the request schema (or carry an explicit `naturally idempotent` exemption marker in the schema's description). Verb prefixes are not authoritative: polymorphic tools such as get_products are mutating because some valid request arms create asynchronous tasks or commit state, even though other arms complete as reads." + "description": "True if invoking this tool can change server-side state. New mutating tools MUST require idempotency_key (or carry an explicit naturally-idempotent exemption). A stable 3.x compatibility facade may explicitly mark the key optional while narrower replacement tools carry the required-key contract. Verb prefixes are not authoritative." + }, + "operation_family": { + "type": "string", + "description": "Stable logical operation identity used for authorization, idempotency equivalence, task recovery, and webhook identity. Aliases in the same family share this value." + }, + "idempotency_requirement": { + "type": "string", + "enum": ["required", "optional", "none"], + "description": "Whether callers must, may, or cannot supply idempotency_key for this tool. Optional keys, when supplied, receive the same replay guarantees as required keys." }, "request_schema": { "type": "string", diff --git a/static/schemas/source/media-buy/finalize-proposals-async-response-input-required.json b/static/schemas/source/media-buy/finalize-proposals-async-response-input-required.json new file mode 100644 index 0000000000..63877ee3d9 --- /dev/null +++ b/static/schemas/source/media-buy/finalize-proposals-async-response-input-required.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/finalize-proposals-async-response-input-required.json", + "title": "Finalize Proposals Input Required Response", + "description": "Input-required payload for paused atomic proposal finalization.", + "allOf": [{ "$ref": "/schemas/media-buy/get-products-async-response-input-required.json" }] +} diff --git a/static/schemas/source/media-buy/finalize-proposals-async-response-submitted.json b/static/schemas/source/media-buy/finalize-proposals-async-response-submitted.json new file mode 100644 index 0000000000..3729f77950 --- /dev/null +++ b/static/schemas/source/media-buy/finalize-proposals-async-response-submitted.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/finalize-proposals-async-response-submitted.json", + "title": "Finalize Proposals Submitted Response", + "description": "Submitted task envelope for asynchronous atomic proposal finalization.", + "allOf": [{ "$ref": "/schemas/media-buy/get-products-async-response-submitted.json" }] +} diff --git a/static/schemas/source/media-buy/finalize-proposals-async-response-working.json b/static/schemas/source/media-buy/finalize-proposals-async-response-working.json new file mode 100644 index 0000000000..971579207d --- /dev/null +++ b/static/schemas/source/media-buy/finalize-proposals-async-response-working.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/finalize-proposals-async-response-working.json", + "title": "Finalize Proposals Working Response", + "description": "Progress payload for active atomic proposal finalization.", + "allOf": [{ "$ref": "/schemas/media-buy/get-products-async-response-working.json" }] +} diff --git a/static/schemas/source/media-buy/finalize-proposals-request.json b/static/schemas/source/media-buy/finalize-proposals-request.json new file mode 100644 index 0000000000..eb7eef6de3 --- /dev/null +++ b/static/schemas/source/media-buy/finalize-proposals-request.json @@ -0,0 +1,74 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/finalize-proposals-request.json", + "title": "Finalize Proposals Request", + "description": "AdCP 3.2 additive request for atomically committing one or more draft proposals to firm pricing and inventory holds. It replaces exclusive get_products refine entries with action: finalize.", + "type": "object", + "x-mutates-state": true, + "x-operation-family": "get_products", + "x-added-in": "3.2.0", + "allOf": [ + { + "$ref": "/schemas/core/version-envelope.json" + }, + { + "not": { + "anyOf": [ + { "required": ["buying_mode"] }, + { "required": ["brief"] }, + { "required": ["refine"] }, + { "required": ["catalog"] }, + { "required": ["filters"] }, + { "required": ["fields"] }, + { "required": ["preferred_delivery_types"] }, + { "required": ["property_list"] }, + { "required": ["required_policies"] }, + { "required": ["if_wholesale_feed_version"] }, + { "required": ["if_pricing_version"] }, + { "required": ["pagination"] }, + { "required": ["time_budget"] } + ] + } + } + ], + "properties": { + "idempotency_key": { + "type": "string", + "description": "Client-generated key required for retry-safe atomic proposal finalization.", + "minLength": 16, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{16,255}$" + }, + "proposal_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "x-entity": "proposal" + }, + "description": "Proposal IDs to commit. Multi-proposal requests retain the atomic pre-commit validation contract of legacy get_products finalization." + }, + "account": { + "$ref": "/schemas/core/account-ref.json" + }, + "brand": { + "$ref": "/schemas/core/brand-ref.json" + }, + "context": { + "$ref": "/schemas/core/context.json" + }, + "push_notification_config": { + "$ref": "/schemas/core/push-notification-config.json" + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": [ + "idempotency_key", + "proposal_ids" + ], + "additionalProperties": true +} diff --git a/static/schemas/source/media-buy/finalize-proposals-response.json b/static/schemas/source/media-buy/finalize-proposals-response.json new file mode 100644 index 0000000000..186396683a --- /dev/null +++ b/static/schemas/source/media-buy/finalize-proposals-response.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/finalize-proposals-response.json", + "title": "Finalize Proposals Response", + "description": "Proposal-finalization response. The wire shape is intentionally identical to an exclusive get_products finalize response during the AdCP 3.2 alias window.", + "allOf": [{ "$ref": "/schemas/media-buy/get-products-response.json" }] +} diff --git a/static/schemas/source/media-buy/get-products-request.json b/static/schemas/source/media-buy/get-products-request.json index b3a4a06c75..3394585030 100644 --- a/static/schemas/source/media-buy/get-products-request.json +++ b/static/schemas/source/media-buy/get-products-request.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/get-products-request.json", "title": "Get Products Request", - "description": "Request parameters for discovering or refining advertising products. buying_mode declares the buyer's intent: 'brief' for curated discovery, 'wholesale' for raw wholesale product feed access, or 'refine' to iterate on known products and proposals.", + "description": "AdCP 3.x compatibility request for discovering, refining, or finalizing advertising products and proposals. AdCP 3.2 introduces the narrower list_products, recommend_products, refine_proposal, and finalize_proposals tasks; new callers SHOULD use those tasks. Existing get_products payloads remain valid throughout 3.x.", "type": "object", "allOf": [ { @@ -37,10 +37,13 @@ } ], "x-mutates-state": true, + "x-idempotency-key-required": false, + "x-operation-family": "get_products", + "x-deprecated-in": "3.2.0", "properties": { "idempotency_key": { "type": "string", - "description": "Client-generated unique key for this request. get_products is polymorphic: a call may complete as a read, create an asynchronous task, or finalize a proposal and commit an inventory hold. Reusing the same key with the same canonical payload returns the original response without repeating those effects. MUST be unique per (seller, request) pair to prevent cross-seller correlation. Use a fresh UUID v4 for each new request and reuse it only for retries.", + "description": "Optional client-generated key for retry-safe use of the AdCP 3.x compatibility facade. Sellers MUST honor replay semantics when the key is supplied, including across the equivalent AdCP 3.2 split-task alias. New callers SHOULD use list_products, recommend_products, refine_proposal, or finalize_proposals; the three stateful split tasks require a key. Keys MUST be unique per (seller, logical request) pair to prevent cross-seller correlation.", "minLength": 16, "maxLength": 255, "pattern": "^[A-Za-z0-9_.:-]{16,255}$" @@ -277,7 +280,6 @@ } }, "required": [ - "idempotency_key", "buying_mode" ], "dependencies": { diff --git a/static/schemas/source/media-buy/list-products-request.json b/static/schemas/source/media-buy/list-products-request.json new file mode 100644 index 0000000000..b571a43d2b --- /dev/null +++ b/static/schemas/source/media-buy/list-products-request.json @@ -0,0 +1,95 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/list-products-request.json", + "title": "List Products Request", + "description": "AdCP 3.2 additive wholesale product-feed request. This is the synchronous, side-effect-free replacement for get_products with buying_mode: wholesale. The legacy get_products form remains supported throughout 3.x.", + "type": "object", + "x-operation-family": "get_products", + "x-added-in": "3.2.0", + "allOf": [ + { + "$ref": "/schemas/core/version-envelope.json" + }, + { + "if": { + "required": [ + "if_pricing_version" + ] + }, + "then": { + "required": [ + "if_wholesale_feed_version" + ] + } + }, + { + "not": { + "anyOf": [ + { "required": ["buying_mode"] }, + { "required": ["brief"] }, + { "required": ["refine"] } + ] + } + } + ], + "properties": { + "idempotency_key": { + "type": "string", + "description": "Optional client-generated key. When supplied, sellers MUST honor replay semantics across list_products and the equivalent legacy get_products request.", + "minLength": 16, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{16,255}$" + }, + "account": { + "$ref": "/schemas/core/account-ref.json" + }, + "brand": { + "$ref": "/schemas/core/brand-ref.json" + }, + "catalog": { + "$ref": "/schemas/core/catalog.json" + }, + "context": { + "$ref": "/schemas/core/context.json" + }, + "ext": { + "$ref": "/schemas/core/ext.json" + }, + "fields": { + "$ref": "/schemas/media-buy/product-fields.json" + }, + "filters": { + "$ref": "/schemas/core/product-filters.json" + }, + "if_wholesale_feed_version": { + "type": "string", + "description": "Opaque wholesale feed version token returned by a prior list_products or equivalent get_products response." + }, + "if_pricing_version": { + "type": "string", + "description": "Opaque pricing version token. Valid only with if_wholesale_feed_version." + }, + "pagination": { + "$ref": "/schemas/core/pagination-request.json" + }, + "property_list": { + "$ref": "/schemas/core/property-list-ref.json" + }, + "push_notification_config": { + "$ref": "/schemas/core/push-notification-config.json", + "description": "Envelope-level callback configuration accepted for wrapper portability. list_products is synchronous and ignores this field." + }, + "required_policies": { + "type": "array", + "description": "Registry policy IDs that the buyer requires products to enforce.", + "items": { "type": "string" } + }, + "time_budget": { + "$ref": "/schemas/core/duration.json" + } + }, + "dependencies": { + "catalog": ["brand"] + }, + "additionalProperties": true +} diff --git a/static/schemas/source/media-buy/list-products-response.json b/static/schemas/source/media-buy/list-products-response.json new file mode 100644 index 0000000000..3549e65b5a --- /dev/null +++ b/static/schemas/source/media-buy/list-products-response.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/list-products-response.json", + "title": "List Products Response", + "description": "Wholesale product-feed response. The wire shape is intentionally identical to the completed wholesale arm of get_products during the AdCP 3.2 alias window.", + "allOf": [{ "$ref": "/schemas/media-buy/get-products-response.json" }] +} diff --git a/static/schemas/source/media-buy/product-fields.json b/static/schemas/source/media-buy/product-fields.json new file mode 100644 index 0000000000..0e7f310b16 --- /dev/null +++ b/static/schemas/source/media-buy/product-fields.json @@ -0,0 +1,56 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/product-fields.json", + "title": "Product Response Fields", + "description": "Product fields a buyer requests in a product-discovery response. Required product_id and name fields are always returned.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "product_id", + "name", + "description", + "publisher_properties", + "channels", + "video_placement_types", + "audio_distribution_types", + "sponsored_placement_types", + "social_placement_surfaces", + "format_ids", + "format_options", + "placements", + "delivery_type", + "exclusivity", + "pricing_options", + "forecast", + "outcome_measurement", + "delivery_measurement", + "reporting_capabilities", + "creative_policy", + "catalog_types", + "metric_optimization", + "conversion_tracking", + "data_provider_signals", + "included_signals", + "signal_targeting_allowed", + "signal_targeting_options", + "signal_targeting_rules", + "demographic_targeting", + "audience_evidence", + "audience_evidence_selections", + "max_optimization_goals", + "catalog_match", + "collections", + "collection_targeting_allowed", + "installments", + "brief_relevance", + "expires_at", + "product_card", + "product_card_detailed", + "enforced_policies", + "trusted_match" + ] + } +} diff --git a/static/schemas/source/media-buy/product-refinement.json b/static/schemas/source/media-buy/product-refinement.json new file mode 100644 index 0000000000..51b40960f2 --- /dev/null +++ b/static/schemas/source/media-buy/product-refinement.json @@ -0,0 +1,86 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/product-refinement.json", + "title": "Product Refinement Requests", + "description": "Change requests for iterating on product discovery results and proposals.", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "discriminator": { + "propertyName": "scope" + }, + "oneOf": [ + { + "properties": { + "scope": { + "type": "string", + "const": "request", + "description": "Change scoped to the overall request." + }, + "ask": { + "type": "string", + "minLength": 1, + "description": "What the buyer is asking for at the request level." + } + }, + "required": ["scope", "ask"], + "additionalProperties": false + }, + { + "properties": { + "scope": { + "type": "string", + "const": "product", + "description": "Change scoped to a specific product." + }, + "product_id": { + "type": "string", + "minLength": 1, + "description": "Product ID from a previous product-discovery response." + }, + "action": { + "type": "string", + "enum": ["include", "omit", "more_like_this"], + "default": "include", + "description": "Requested product-level action." + }, + "ask": { + "type": "string", + "minLength": 1, + "description": "What the buyer is asking for on this product." + } + }, + "required": ["scope", "product_id"], + "additionalProperties": false + }, + { + "properties": { + "scope": { + "type": "string", + "const": "proposal", + "description": "Change scoped to a specific proposal." + }, + "proposal_id": { + "type": "string", + "minLength": 1, + "description": "Proposal ID from a previous product-discovery response." + }, + "action": { + "type": "string", + "enum": ["include", "omit", "finalize"], + "default": "include", + "description": "Requested proposal-level action." + }, + "ask": { + "type": "string", + "minLength": 1, + "description": "What the buyer is asking for on this proposal." + } + }, + "required": ["scope", "proposal_id"], + "additionalProperties": false + } + ] + } +} diff --git a/static/schemas/source/media-buy/recommend-products-async-response-input-required.json b/static/schemas/source/media-buy/recommend-products-async-response-input-required.json new file mode 100644 index 0000000000..9e0dd7bd69 --- /dev/null +++ b/static/schemas/source/media-buy/recommend-products-async-response-input-required.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/recommend-products-async-response-input-required.json", + "title": "Recommend Products Input Required Response", + "description": "Input-required payload for paused curated product recommendation.", + "allOf": [{ "$ref": "/schemas/media-buy/get-products-async-response-input-required.json" }] +} diff --git a/static/schemas/source/media-buy/recommend-products-async-response-submitted.json b/static/schemas/source/media-buy/recommend-products-async-response-submitted.json new file mode 100644 index 0000000000..2c6e3516e2 --- /dev/null +++ b/static/schemas/source/media-buy/recommend-products-async-response-submitted.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/recommend-products-async-response-submitted.json", + "title": "Recommend Products Submitted Response", + "description": "Submitted task envelope for asynchronous curated product recommendation.", + "allOf": [{ "$ref": "/schemas/media-buy/get-products-async-response-submitted.json" }] +} diff --git a/static/schemas/source/media-buy/recommend-products-async-response-working.json b/static/schemas/source/media-buy/recommend-products-async-response-working.json new file mode 100644 index 0000000000..c59f657a92 --- /dev/null +++ b/static/schemas/source/media-buy/recommend-products-async-response-working.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/recommend-products-async-response-working.json", + "title": "Recommend Products Working Response", + "description": "Progress payload for active curated product recommendation.", + "allOf": [{ "$ref": "/schemas/media-buy/get-products-async-response-working.json" }] +} diff --git a/static/schemas/source/media-buy/recommend-products-request.json b/static/schemas/source/media-buy/recommend-products-request.json new file mode 100644 index 0000000000..35c0fc9aa5 --- /dev/null +++ b/static/schemas/source/media-buy/recommend-products-request.json @@ -0,0 +1,92 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/recommend-products-request.json", + "title": "Recommend Products Request", + "description": "AdCP 3.2 additive curated product-discovery request. This replaces get_products with buying_mode: brief. It may allocate an asynchronous task, so idempotency_key is required even when a seller completes synchronously.", + "type": "object", + "x-mutates-state": true, + "x-operation-family": "get_products", + "x-added-in": "3.2.0", + "allOf": [ + { + "$ref": "/schemas/core/version-envelope.json" + }, + { + "not": { + "anyOf": [ + { "required": ["buying_mode"] }, + { "required": ["refine"] }, + { "required": ["if_wholesale_feed_version"] }, + { "required": ["if_pricing_version"] } + ] + } + } + ], + "properties": { + "idempotency_key": { + "type": "string", + "description": "Client-generated key required for retry-safe curated discovery.", + "minLength": 16, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{16,255}$" + }, + "brief": { + "type": "string", + "minLength": 1, + "description": "Natural-language campaign requirements used to curate recommendations." + }, + "account": { + "$ref": "/schemas/core/account-ref.json" + }, + "brand": { + "$ref": "/schemas/core/brand-ref.json" + }, + "catalog": { + "$ref": "/schemas/core/catalog.json" + }, + "context": { + "$ref": "/schemas/core/context.json" + }, + "ext": { + "$ref": "/schemas/core/ext.json" + }, + "fields": { + "$ref": "/schemas/media-buy/product-fields.json" + }, + "filters": { + "$ref": "/schemas/core/product-filters.json" + }, + "pagination": { + "$ref": "/schemas/core/pagination-request.json" + }, + "preferred_delivery_types": { + "type": "array", + "description": "Preferred delivery types in priority order.", + "minItems": 1, + "uniqueItems": true, + "items": { "$ref": "/schemas/enums/delivery-type.json" } + }, + "property_list": { + "$ref": "/schemas/core/property-list-ref.json" + }, + "push_notification_config": { + "$ref": "/schemas/core/push-notification-config.json" + }, + "required_policies": { + "type": "array", + "description": "Registry policy IDs that the buyer requires products to enforce.", + "items": { "type": "string" } + }, + "time_budget": { + "$ref": "/schemas/core/duration.json" + } + }, + "required": [ + "idempotency_key", + "brief" + ], + "dependencies": { + "catalog": ["brand"] + }, + "additionalProperties": true +} diff --git a/static/schemas/source/media-buy/recommend-products-response.json b/static/schemas/source/media-buy/recommend-products-response.json new file mode 100644 index 0000000000..7fae29f9f5 --- /dev/null +++ b/static/schemas/source/media-buy/recommend-products-response.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/recommend-products-response.json", + "title": "Recommend Products Response", + "description": "Curated discovery response. The wire shape is intentionally identical to the brief arm of get_products during the AdCP 3.2 alias window.", + "allOf": [{ "$ref": "/schemas/media-buy/get-products-response.json" }] +} diff --git a/static/schemas/source/media-buy/refine-proposal-async-response-input-required.json b/static/schemas/source/media-buy/refine-proposal-async-response-input-required.json new file mode 100644 index 0000000000..52b77a2832 --- /dev/null +++ b/static/schemas/source/media-buy/refine-proposal-async-response-input-required.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/refine-proposal-async-response-input-required.json", + "title": "Refine Proposal Input Required Response", + "description": "Input-required payload for paused proposal refinement.", + "allOf": [{ "$ref": "/schemas/media-buy/get-products-async-response-input-required.json" }] +} diff --git a/static/schemas/source/media-buy/refine-proposal-async-response-submitted.json b/static/schemas/source/media-buy/refine-proposal-async-response-submitted.json new file mode 100644 index 0000000000..d073fa5ea8 --- /dev/null +++ b/static/schemas/source/media-buy/refine-proposal-async-response-submitted.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/refine-proposal-async-response-submitted.json", + "title": "Refine Proposal Submitted Response", + "description": "Submitted task envelope for asynchronous proposal refinement.", + "allOf": [{ "$ref": "/schemas/media-buy/get-products-async-response-submitted.json" }] +} diff --git a/static/schemas/source/media-buy/refine-proposal-async-response-working.json b/static/schemas/source/media-buy/refine-proposal-async-response-working.json new file mode 100644 index 0000000000..4e9370d512 --- /dev/null +++ b/static/schemas/source/media-buy/refine-proposal-async-response-working.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/refine-proposal-async-response-working.json", + "title": "Refine Proposal Working Response", + "description": "Progress payload for active proposal refinement.", + "allOf": [{ "$ref": "/schemas/media-buy/get-products-async-response-working.json" }] +} diff --git a/static/schemas/source/media-buy/refine-proposal-request.json b/static/schemas/source/media-buy/refine-proposal-request.json new file mode 100644 index 0000000000..a5662f3ba2 --- /dev/null +++ b/static/schemas/source/media-buy/refine-proposal-request.json @@ -0,0 +1,109 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/refine-proposal-request.json", + "title": "Refine Proposal Request", + "description": "AdCP 3.2 additive request for changing one draft proposal. It preserves the get_products refine grammar, but excludes finalization and requires every proposal-scoped entry to name the same proposal. Finalization is a separate finalize_proposals call.", + "type": "object", + "x-mutates-state": true, + "x-operation-family": "get_products", + "x-added-in": "3.2.0", + "allOf": [ + { + "$ref": "/schemas/core/version-envelope.json" + }, + { + "not": { + "anyOf": [ + { "required": ["buying_mode"] }, + { "required": ["brief"] }, + { "required": ["if_wholesale_feed_version"] }, + { "required": ["if_pricing_version"] } + ] + } + } + ], + "properties": { + "idempotency_key": { + "type": "string", + "description": "Client-generated key required for retry-safe proposal refinement.", + "minLength": 16, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{16,255}$" + }, + "refine": { + "allOf": [ + { "$ref": "/schemas/media-buy/product-refinement.json" }, + { + "contains": { + "type": "object", + "properties": { "scope": { "const": "proposal" } }, + "required": ["scope"] + } + }, + { + "not": { + "contains": { + "type": "object", + "properties": { "action": { "const": "finalize" } }, + "required": ["action"] + } + } + } + ], + "description": "Non-finalizing request, product, and proposal changes using the established get_products refine grammar. At least one entry MUST be proposal-scoped, all proposal-scoped entries MUST name the same proposal_id, and action: finalize is invalid on this tool." + }, + "account": { + "$ref": "/schemas/core/account-ref.json" + }, + "brand": { + "$ref": "/schemas/core/brand-ref.json" + }, + "catalog": { + "$ref": "/schemas/core/catalog.json" + }, + "context": { + "$ref": "/schemas/core/context.json" + }, + "ext": { + "$ref": "/schemas/core/ext.json" + }, + "fields": { + "$ref": "/schemas/media-buy/product-fields.json" + }, + "filters": { + "$ref": "/schemas/core/product-filters.json" + }, + "pagination": { + "$ref": "/schemas/core/pagination-request.json" + }, + "preferred_delivery_types": { + "type": "array", + "description": "Preferred delivery types in priority order.", + "minItems": 1, + "uniqueItems": true, + "items": { "$ref": "/schemas/enums/delivery-type.json" } + }, + "property_list": { + "$ref": "/schemas/core/property-list-ref.json" + }, + "push_notification_config": { + "$ref": "/schemas/core/push-notification-config.json" + }, + "required_policies": { + "type": "array", + "description": "Registry policy IDs that the buyer requires products to enforce.", + "items": { "type": "string" } + }, + "time_budget": { + "$ref": "/schemas/core/duration.json" + } + }, + "required": [ + "idempotency_key", + "refine" + ], + "dependencies": { + "catalog": ["brand"] + }, + "additionalProperties": true +} diff --git a/static/schemas/source/media-buy/refine-proposal-response.json b/static/schemas/source/media-buy/refine-proposal-response.json new file mode 100644 index 0000000000..d4ea3ee207 --- /dev/null +++ b/static/schemas/source/media-buy/refine-proposal-response.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/refine-proposal-response.json", + "title": "Refine Proposal Response", + "description": "Proposal-refinement response. The wire shape is intentionally identical to a non-finalizing get_products refine response during the AdCP 3.2 alias window.", + "allOf": [{ "$ref": "/schemas/media-buy/get-products-response.json" }] +} diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index 2fec250a81..67096f1098 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -634,6 +634,16 @@ "uniqueItems": true, "default": ["brief"] }, + "product_discovery_tools": { + "type": "array", + "description": "Product-discovery operation names this seller supports. Added in AdCP 3.2 for the additive split of get_products. Sellers may advertise any supported subset of list_products, recommend_products, refine_proposal, and finalize_proposals while retaining get_products throughout 3.x. Absence means the caller should use the legacy get_products facade and inspect buying_modes. Every advertised split tool shares get_products authorization, idempotency, task-recovery, and webhook operation identity during 3.x.", + "items": { + "type": "string", + "enum": ["get_products", "list_products", "recommend_products", "refine_proposal", "finalize_proposals"] + }, + "minItems": 1, + "uniqueItems": true + }, "reporting_delivery_methods": { "type": "array", "description": "How this seller delivers reporting data to buyers. Polling via get_media_buy_delivery is always available as a baseline regardless of this field. This array declares additional push-based delivery methods the seller supports. 'webhook': seller pushes to buyer-provided URL (configured per buy via reporting_webhook). 'offline': seller pushes batch files to a cloud storage bucket (seller-provisioned per account via reporting_bucket on the account object). When absent, only polling is available.", diff --git a/tests/composed-schema-validation.test.cjs b/tests/composed-schema-validation.test.cjs index 5941c1e346..0397e2e476 100644 --- a/tests/composed-schema-validation.test.cjs +++ b/tests/composed-schema-validation.test.cjs @@ -3015,6 +3015,82 @@ async function runTests() { ); log(''); + log('AdCP 3.2 split product-discovery request contracts:', 'info'); + await testSchemaValidation( + '/schemas/media-buy/get-products-request.json', + { buying_mode: 'wholesale' }, + 'Legacy get_products remains valid without an idempotency key throughout 3.x' + ); + await testSchemaValidation( + '/schemas/media-buy/list-products-request.json', + { fields: ['product_id', 'pricing_options'] }, + 'list_products is a key-optional synchronous read' + ); + await testSchemaValidation( + '/schemas/media-buy/list-products-request.json', + { + push_notification_config: { + url: 'https://buyer.example.com/adcp-events' + } + }, + 'list_products accepts wrapper callback configuration even though the read does not emit callbacks' + ); + await testSchemaRejection( + '/schemas/media-buy/list-products-request.json', + { catalog: { type: 'product' } }, + 'list_products requires brand when catalog is present' + ); + await testSchemaValidation( + '/schemas/media-buy/recommend-products-request.json', + { + idempotency_key: 'recommend-products-0001', + brief: 'Reach streaming audio listeners in Rome' + }, + 'recommend_products requires a brief and accepts a replay key' + ); + await testSchemaRejection( + '/schemas/media-buy/recommend-products-request.json', + { brief: 'Reach streaming audio listeners in Rome' }, + 'recommend_products rejects a missing replay key' + ); + await testSchemaValidation( + '/schemas/media-buy/refine-proposal-request.json', + { + idempotency_key: 'refine-proposal-0001', + refine: [ + { scope: 'request', ask: 'Prefer video' }, + { scope: 'proposal', proposal_id: 'proposal-1', ask: 'Move budget to video' } + ] + }, + 'refine_proposal preserves non-finalizing refinement grammar' + ); + await testSchemaRejection( + '/schemas/media-buy/refine-proposal-request.json', + { + idempotency_key: 'refine-proposal-0002', + refine: [{ scope: 'proposal', proposal_id: 'proposal-1', action: 'finalize' }] + }, + 'refine_proposal rejects finalization' + ); + await testSchemaValidation( + '/schemas/media-buy/finalize-proposals-request.json', + { + idempotency_key: 'finalize-proposals-0001', + proposal_ids: ['proposal-1', 'proposal-2'] + }, + 'finalize_proposals accepts a unique atomic proposal set' + ); + await testSchemaRejection( + '/schemas/media-buy/finalize-proposals-request.json', + { + idempotency_key: 'finalize-proposals-0002', + proposal_ids: ['proposal-1', 'proposal-1'] + }, + 'finalize_proposals rejects duplicate proposal IDs' + ); + + log(''); + log('SignalId compatibility during SignalRef migration:', 'info'); await testSchemaValidation( '/schemas/signals/get-signals-response.json', diff --git a/tests/docs-nav-validation.test.cjs b/tests/docs-nav-validation.test.cjs index 94526b10f2..74871396aa 100644 --- a/tests/docs-nav-validation.test.cjs +++ b/tests/docs-nav-validation.test.cjs @@ -247,6 +247,10 @@ test('temporary snapshot redirects cover every available live page', () => { 'docs/reference/migration/cross-role-governance-enforcement', 'docs/protocol/language-and-localization', 'docs/protocol/sync_agent_notification_configs', + 'docs/media-buy/task-reference/list_products', + 'docs/media-buy/task-reference/recommend_products', + 'docs/media-buy/task-reference/refine_proposal', + 'docs/media-buy/task-reference/finalize_proposals', 'docs/creative/channels/radio', 'docs/brand-protocol/tasks/search_brands', ]; From 0e552236888a08062fef5c2074308815fca60d9c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 10 Aug 2026 09:14:41 +0200 Subject: [PATCH 12/49] docs(media-buy): add SEO metadata for split tools --- docs/media-buy/task-reference/finalize_proposals.mdx | 3 ++- docs/media-buy/task-reference/list_products.mdx | 1 + docs/media-buy/task-reference/recommend_products.mdx | 3 ++- docs/media-buy/task-reference/refine_proposal.mdx | 3 ++- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/media-buy/task-reference/finalize_proposals.mdx b/docs/media-buy/task-reference/finalize_proposals.mdx index a2d070c9e3..baf166a295 100644 --- a/docs/media-buy/task-reference/finalize_proposals.mdx +++ b/docs/media-buy/task-reference/finalize_proposals.mdx @@ -1,6 +1,7 @@ --- title: finalize_proposals -description: "Atomically commit one or more draft proposals." +description: "Atomically commit one or more draft product proposals." +"og:title": "AdCP finalize_proposals Task" testable: true --- diff --git a/docs/media-buy/task-reference/list_products.mdx b/docs/media-buy/task-reference/list_products.mdx index 4cc8735334..4c71f5b683 100644 --- a/docs/media-buy/task-reference/list_products.mdx +++ b/docs/media-buy/task-reference/list_products.mdx @@ -1,6 +1,7 @@ --- title: list_products description: "List the seller's synchronous wholesale product feed." +"og:title": "AdCP list_products Task" testable: true --- diff --git a/docs/media-buy/task-reference/recommend_products.mdx b/docs/media-buy/task-reference/recommend_products.mdx index 23389a3260..2d80081ad3 100644 --- a/docs/media-buy/task-reference/recommend_products.mdx +++ b/docs/media-buy/task-reference/recommend_products.mdx @@ -1,6 +1,7 @@ --- title: recommend_products -description: "Request curated products from a campaign brief." +description: "Request seller-curated products from a campaign brief." +"og:title": "AdCP recommend_products Task" testable: true --- diff --git a/docs/media-buy/task-reference/refine_proposal.mdx b/docs/media-buy/task-reference/refine_proposal.mdx index a26ebba115..331a86a01e 100644 --- a/docs/media-buy/task-reference/refine_proposal.mdx +++ b/docs/media-buy/task-reference/refine_proposal.mdx @@ -1,6 +1,7 @@ --- title: refine_proposal -description: "Refine one draft proposal without committing it." +description: "Refine one draft proposal without committing the proposal." +"og:title": "AdCP refine_proposal Task" testable: true --- From 3725a975abbde8b758b0a869251abce0dfb551aa Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 10 Aug 2026 09:19:06 +0200 Subject: [PATCH 13/49] docs(media-buy): pin split tool schema links to v3 --- docs/media-buy/task-reference/finalize_proposals.mdx | 2 +- docs/media-buy/task-reference/list_products.mdx | 2 +- docs/media-buy/task-reference/recommend_products.mdx | 2 +- docs/media-buy/task-reference/refine_proposal.mdx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/media-buy/task-reference/finalize_proposals.mdx b/docs/media-buy/task-reference/finalize_proposals.mdx index baf166a295..1232a9c9ad 100644 --- a/docs/media-buy/task-reference/finalize_proposals.mdx +++ b/docs/media-buy/task-reference/finalize_proposals.mdx @@ -7,7 +7,7 @@ testable: true `finalize_proposals` is the explicit AdCP 3.2 proposal commit boundary. `idempotency_key` is required, and `proposal_ids` must be non-empty and unique. -**Request schema:** [`/schemas/latest/media-buy/finalize-proposals-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/finalize-proposals-request.json) +**Request schema:** [`/schemas/v3/media-buy/finalize-proposals-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/finalize-proposals-request.json) ```json { diff --git a/docs/media-buy/task-reference/list_products.mdx b/docs/media-buy/task-reference/list_products.mdx index 4c71f5b683..c89bc6f522 100644 --- a/docs/media-buy/task-reference/list_products.mdx +++ b/docs/media-buy/task-reference/list_products.mdx @@ -7,7 +7,7 @@ testable: true `list_products` is the AdCP 3.2 side-effect-free replacement for `get_products` with `buying_mode: "wholesale"`. It never allocates an asynchronous task. `idempotency_key` is optional; when supplied, the seller applies the normal replay contract. -**Request schema:** [`/schemas/latest/media-buy/list-products-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/list-products-request.json) +**Request schema:** [`/schemas/v3/media-buy/list-products-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/list-products-request.json) ```json { diff --git a/docs/media-buy/task-reference/recommend_products.mdx b/docs/media-buy/task-reference/recommend_products.mdx index 2d80081ad3..ac15e131cc 100644 --- a/docs/media-buy/task-reference/recommend_products.mdx +++ b/docs/media-buy/task-reference/recommend_products.mdx @@ -7,7 +7,7 @@ testable: true `recommend_products` is the AdCP 3.2 replacement for `get_products` with `buying_mode: "brief"`. It may allocate an asynchronous task, so `idempotency_key` is required. -**Request schema:** [`/schemas/latest/media-buy/recommend-products-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/recommend-products-request.json) +**Request schema:** [`/schemas/v3/media-buy/recommend-products-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/recommend-products-request.json) ```json { diff --git a/docs/media-buy/task-reference/refine_proposal.mdx b/docs/media-buy/task-reference/refine_proposal.mdx index 331a86a01e..4ccbe6b2d9 100644 --- a/docs/media-buy/task-reference/refine_proposal.mdx +++ b/docs/media-buy/task-reference/refine_proposal.mdx @@ -7,7 +7,7 @@ testable: true `refine_proposal` preserves the established `refine[]` grammar while making the non-commit boundary explicit. `idempotency_key` is required. At least one entry must be proposal-scoped, and every proposal-scoped entry must name the same `proposal_id`. `action: "finalize"` is invalid; use `finalize_proposals`. -**Request schema:** [`/schemas/latest/media-buy/refine-proposal-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/refine-proposal-request.json) +**Request schema:** [`/schemas/v3/media-buy/refine-proposal-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/refine-proposal-request.json) ```json { From 7fc414688943579dd93862e42aa6dc65d33a4407 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 10 Aug 2026 09:24:58 +0200 Subject: [PATCH 14/49] fix(media-buy): register split tools in tenant catalogs --- scripts/lint-storyboard-scoping.cjs | 4 ++++ .../training-agent/tenants/tool-catalog.ts | 19 +++++++++++++++---- .../training-agent-tool-catalog-drift.test.ts | 12 ++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/scripts/lint-storyboard-scoping.cjs b/scripts/lint-storyboard-scoping.cjs index 5915236b0c..eecbe2fc62 100644 --- a/scripts/lint-storyboard-scoping.cjs +++ b/scripts/lint-storyboard-scoping.cjs @@ -47,6 +47,10 @@ const TENANT_SCOPED_TASKS = new Set([ 'validate_input', // Products & signals 'get_products', + 'list_products', + 'recommend_products', + 'refine_proposal', + 'finalize_proposals', 'get_signals', 'activate_signal', 'sync_audiences', diff --git a/server/src/training-agent/tenants/tool-catalog.ts b/server/src/training-agent/tenants/tool-catalog.ts index 657bdba9e6..375cb9d083 100644 --- a/server/src/training-agent/tenants/tool-catalog.ts +++ b/server/src/training-agent/tenants/tool-catalog.ts @@ -34,6 +34,10 @@ export const TOOL_CATALOG: Readonly> = { // sales sync_catalogs: ['sales', 'si'], get_products: ['sales'], + list_products: ['sales'], + recommend_products: ['sales'], + refine_proposal: ['sales'], + finalize_proposals: ['sales'], create_media_buy: ['sales'], update_media_buy: ['sales'], get_media_buys: ['sales'], @@ -126,10 +130,17 @@ export function toolsForTenant( const is30 = options.storyboardCompat?.version === '3.0' || options.adcpVersion?.startsWith('3.0'); if (!is30) return true; - // 3.0-compat exclusions. validate_input / list_transformers are gated off - // on every tenant that serves them. sync_governance is a 3.1+ account task - // gated off /sales under 3.0 (the released 3.0.x sales scenarios skip it), - // but /signals keeps it across versions. + // 3.0-compat exclusions. The split product-discovery tools are introduced + // in 3.2, while validate_input / list_transformers are gated off on every + // tenant that serves them. sync_governance is a 3.1+ account task gated + // off /sales under 3.0 (the released 3.0.x sales scenarios skip it), but + // /signals keeps it across versions. + if ( + tool === 'list_products' + || tool === 'recommend_products' + || tool === 'refine_proposal' + || tool === 'finalize_proposals' + ) return false; if (tool === 'validate_input' || tool === 'list_transformers') return false; if (tool === 'sync_governance' && tenantId === 'sales') return false; return true; diff --git a/server/tests/integration/training-agent-tool-catalog-drift.test.ts b/server/tests/integration/training-agent-tool-catalog-drift.test.ts index 26774d7670..5373d03ede 100644 --- a/server/tests/integration/training-agent-tool-catalog-drift.test.ts +++ b/server/tests/integration/training-agent-tool-catalog-drift.test.ts @@ -140,4 +140,16 @@ describe('tool-catalog drift detection', () => { } } }); + + it('keeps AdCP 3.2 product-discovery tools out of the 3.0 compatibility catalog', () => { + const compatibilityCatalog = toolsForTenant('sales', { + storyboardCompat: { version: '3.0' }, + }); + expect(compatibilityCatalog).not.toEqual(expect.arrayContaining([ + 'list_products', + 'recommend_products', + 'refine_proposal', + 'finalize_proposals', + ])); + }); }); From 937c4bff6f07de3310bdd363d5748dde38095efe Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 10 Aug 2026 09:29:58 +0200 Subject: [PATCH 15/49] test(media-buy): check each 3.0 catalog exclusion --- .../integration/training-agent-tool-catalog-drift.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/server/tests/integration/training-agent-tool-catalog-drift.test.ts b/server/tests/integration/training-agent-tool-catalog-drift.test.ts index 5373d03ede..2d254f3702 100644 --- a/server/tests/integration/training-agent-tool-catalog-drift.test.ts +++ b/server/tests/integration/training-agent-tool-catalog-drift.test.ts @@ -145,11 +145,14 @@ describe('tool-catalog drift detection', () => { const compatibilityCatalog = toolsForTenant('sales', { storyboardCompat: { version: '3.0' }, }); - expect(compatibilityCatalog).not.toEqual(expect.arrayContaining([ + const splitTools = [ 'list_products', 'recommend_products', 'refine_proposal', 'finalize_proposals', - ])); + ]; + for (const tool of splitTools) { + expect(compatibilityCatalog).not.toContain(tool); + } }); }); From c5963867ca7edc8defc064efccd0bd3b8fe62ac4 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 11 Aug 2026 00:36:06 +0200 Subject: [PATCH 16/49] feat(media-buy): define compact proposal lifecycle --- .changeset/secure-get-products-idempotency.md | 2 +- docs.json | 4 +- docs/accounts/overview.mdx | 2 +- docs/building/by-layer/L1/security.mdx | 12 +- docs/building/by-layer/L3/error-handling.mdx | 4 +- .../product-discovery/media-products.mdx | 2 +- docs/media-buy/specification.mdx | 4 +- .../task-reference/create_media_buy.mdx | 12 +- .../task-reference/finalize_proposals.mdx | 7 +- .../media-buy/task-reference/get_products.mdx | 2 +- .../task-reference/list_products.mdx | 23 +- .../task-reference/recommend_products.mdx | 23 - .../task-reference/refine_proposal.mdx | 26 - .../task-reference/refine_proposals.mdx | 29 + .../task-reference/request_proposals.mdx | 26 + docs/protocol/get_adcp_capabilities.mdx | 6 +- docs/reference/release-notes.mdx | 12 +- package-lock.json | 2 +- package.json | 2 +- scripts/build-schemas.cjs | 26 +- scripts/error-code-drift-dispositions.json | 2 +- scripts/lint-storyboard-scoping.cjs | 4 +- server/src/training-agent/idempotency.ts | 4 +- server/src/training-agent/source-schema.ts | 122 ++- server/src/training-agent/task-handlers.ts | 772 +++++++++++++++--- server/src/training-agent/tenants/router.ts | 4 +- .../tenants/tenant-smoke.test.ts | 52 +- .../training-agent/tenants/tool-catalog.ts | 8 +- .../training-agent-tool-catalog-drift.test.ts | 4 +- .../training-agent-webhooks.test.ts | 31 +- server/tests/unit/idempotency.test.ts | 2 +- .../product-discovery-schema-parity.test.ts | 91 +-- .../unit/training-agent-idempotency.test.ts | 197 +++-- server/tests/unit/training-agent.test.ts | 156 +++- .../source/core/account-authorization.json | 6 +- static/schemas/source/core/brand-key.json | 20 + .../source/core/catalog-selection.json | 35 + .../source/core/product-offer-filters.json | 48 ++ static/schemas/source/core/proposal.json | 2 +- .../schemas/source/core/x-entity-types.json | 2 +- static/schemas/source/enums/error-code.json | 8 +- static/schemas/source/index.json | 44 +- .../media-buy/create-media-buy-request.json | 2 +- ...oposals-async-response-input-required.json | 7 - ...ze-proposals-async-response-submitted.json | 7 - ...lize-proposals-async-response-working.json | 7 - .../media-buy/finalize-proposals-request.json | 75 +- .../finalize-proposals-response.json | 31 +- .../media-buy/get-products-request.json | 4 +- .../media-buy/list-products-request.json | 109 +-- .../media-buy/list-products-response.json | 38 +- .../media-buy/product-discovery-criteria.json | 27 + .../source/media-buy/proposal-refinement.json | 21 + ...roducts-async-response-input-required.json | 7 - ...end-products-async-response-submitted.json | 7 - ...mmend-products-async-response-working.json | 7 - .../media-buy/recommend-products-request.json | 92 --- .../recommend-products-response.json | 7 - ...roposal-async-response-input-required.json | 7 - ...ine-proposal-async-response-submitted.json | 7 - ...efine-proposal-async-response-working.json | 7 - .../media-buy/refine-proposal-request.json | 109 --- .../media-buy/refine-proposal-response.json | 7 - .../media-buy/refine-proposals-request.json | 40 + .../media-buy/refine-proposals-response.json | 83 ++ .../media-buy/request-proposals-request.json | 46 ++ .../media-buy/request-proposals-response.json | 68 ++ .../get-adcp-capabilities-response.json | 34 +- tests/composed-schema-validation.test.cjs | 159 +++- tests/docs-nav-validation.test.cjs | 4 +- tests/schema-validation.test.cjs | 26 +- 71 files changed, 2044 insertions(+), 841 deletions(-) delete mode 100644 docs/media-buy/task-reference/recommend_products.mdx delete mode 100644 docs/media-buy/task-reference/refine_proposal.mdx create mode 100644 docs/media-buy/task-reference/refine_proposals.mdx create mode 100644 docs/media-buy/task-reference/request_proposals.mdx create mode 100644 static/schemas/source/core/brand-key.json create mode 100644 static/schemas/source/core/catalog-selection.json create mode 100644 static/schemas/source/core/product-offer-filters.json delete mode 100644 static/schemas/source/media-buy/finalize-proposals-async-response-input-required.json delete mode 100644 static/schemas/source/media-buy/finalize-proposals-async-response-submitted.json delete mode 100644 static/schemas/source/media-buy/finalize-proposals-async-response-working.json create mode 100644 static/schemas/source/media-buy/product-discovery-criteria.json create mode 100644 static/schemas/source/media-buy/proposal-refinement.json delete mode 100644 static/schemas/source/media-buy/recommend-products-async-response-input-required.json delete mode 100644 static/schemas/source/media-buy/recommend-products-async-response-submitted.json delete mode 100644 static/schemas/source/media-buy/recommend-products-async-response-working.json delete mode 100644 static/schemas/source/media-buy/recommend-products-request.json delete mode 100644 static/schemas/source/media-buy/recommend-products-response.json delete mode 100644 static/schemas/source/media-buy/refine-proposal-async-response-input-required.json delete mode 100644 static/schemas/source/media-buy/refine-proposal-async-response-submitted.json delete mode 100644 static/schemas/source/media-buy/refine-proposal-async-response-working.json delete mode 100644 static/schemas/source/media-buy/refine-proposal-request.json delete mode 100644 static/schemas/source/media-buy/refine-proposal-response.json create mode 100644 static/schemas/source/media-buy/refine-proposals-request.json create mode 100644 static/schemas/source/media-buy/refine-proposals-response.json create mode 100644 static/schemas/source/media-buy/request-proposals-request.json create mode 100644 static/schemas/source/media-buy/request-proposals-response.json diff --git a/.changeset/secure-get-products-idempotency.md b/.changeset/secure-get-products-idempotency.md index febd68695e..41a5b8206b 100644 --- a/.changeset/secure-get-products-idempotency.md +++ b/.changeset/secure-get-products-idempotency.md @@ -2,4 +2,4 @@ "adcontextprotocol": minor --- -Add the AdCP 3.2 `list_products`, `recommend_products`, `refine_proposal`, and `finalize_proposals` tools. The split tools give reads, recommendations, refinements, and commits explicit idempotency contracts while retaining `get_products` as a key-optional compatibility facade throughout 3.x. Keyed retries are equivalent across legacy and split names. +Add the compact AdCP 3.2 product lifecycle: `list_products`, `request_proposals`, `refine_proposals`, and `finalize_proposals`. The task-specific contracts separate offer discovery from proposal creation, immutable refinement, atomic commitment, and purchase while retaining `get_products` as a key-optional compatibility facade throughout 3.x. diff --git a/docs.json b/docs.json index af52ac81f0..36b89c35ee 100644 --- a/docs.json +++ b/docs.json @@ -294,8 +294,8 @@ "docs/media-buy/task-reference/index", "docs/media-buy/task-reference/get_products", "docs/media-buy/task-reference/list_products", - "docs/media-buy/task-reference/recommend_products", - "docs/media-buy/task-reference/refine_proposal", + "docs/media-buy/task-reference/request_proposals", + "docs/media-buy/task-reference/refine_proposals", "docs/media-buy/task-reference/finalize_proposals", "docs/media-buy/task-reference/create_media_buy", "docs/media-buy/task-reference/sync_catalogs", diff --git a/docs/accounts/overview.mdx b/docs/accounts/overview.mdx index 9dae22043f..b731aa4c19 100644 --- a/docs/accounts/overview.mdx +++ b/docs/accounts/overview.mdx @@ -144,7 +144,7 @@ Vendor agents that support scope introspection attach an `authorization` object | `scope_name` | Optional named scope identifier. Only `attestation_verifier` is standardized (media-buy-specific, binds to the **AAO Verified (Live)** qualifier); agent-defined names MUST use the `custom:` prefix so typos of the standard value fail schema validation rather than pass through. | | `read_only` | Convenience flag. When true, mutations return `READ_ONLY_SCOPE` regardless of whether the task is in `allowed_tasks`. Omission is equivalent to `false`. Callers MUST NOT infer read-only from `allowed_tasks` alone. | -**AdCP 3.2 product aliases.** `get_products` is the authorization name for the full product-discovery operation family, including every split tool the seller advertises. Sellers normalize split requests to canonical `get_products` fields before applying its `field_scopes`; alias-only grants and alias-specific field scopes are not defined during the 3.x compatibility window. A read-only grant permits `list_products`, rejects the three mutation-capable split tools, and permits legacy `get_products` only when the selected arm is guaranteed to remain a synchronous side-effect-free read. +**AdCP 3.2 compact product tools.** Authorization names the actual task. A `get_products` grant does not silently authorize `request_proposals`, `refine_proposals`, or `finalize_proposals`; sellers grant the proposal lifecycle explicitly and may scope its compact top-level fields independently. A read-only grant may permit `list_products` but rejects the three mutation-capable proposal tools. ### Semantics of presence and absence diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index 10205aabef..8d73d23146 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -399,13 +399,15 @@ A daypart with no declared semantics is ambiguous and MUST be rejected with `INV `idempotency_key` is **required on every state-mutating AdCP task request**. Guaranteed pure-read tasks may leave it optional, but they MUST accept and apply the replay contract when a caller supplies one. Keys are scoped per `(authenticated agent, account)` — they have no meaning across agents on the same seller, across accounts under the same agent, or across sellers. -**Product-discovery migration in 3.2.** AdCP 3.2 adds four explicit tools around the stable `get_products` operation family: +**Product-discovery migration in 3.2.** AdCP 3.2 adds four compact tools alongside the stable `get_products` facade: -- `list_products` is a side-effect-free synchronous read; its key is optional. -- `recommend_products`, `refine_proposal`, and `finalize_proposals` require a key because they can allocate tasks or change proposal state. +- `list_products` is a naturally idempotent synchronous read; its key is optional and sellers apply the replay contract when one is supplied. +- `request_proposals`, `refine_proposals`, and `finalize_proposals` require a key because they can allocate tasks or change proposal state. - `get_products` remains valid throughout 3.x as a deprecated compatibility facade, with an optional key. Sellers MUST honor the replay contract whenever the caller supplies one. -Aliases MUST normalize to the same canonical `get_products` payload before authorization, idempotency hashing, task recovery, persistence, and webhook emission. A retry may therefore switch between the legacy and split name without duplicating work; the same key with a different canonical payload returns `IDEMPOTENCY_CONFLICT`. +The compact tools reuse legacy implementation paths where useful but are distinct logical operations. A retry MUST keep the same tool name and payload. Reusing a key on another tool is a different payload and returns `IDEMPOTENCY_CONFLICT`; callers must not switch names after an ambiguous failure. + +`request_proposals` binds each opaque proposal ID to its full BrandKey (`domain` plus optional `brand_id`) and, when supplied, its seller account. `refine_proposals` and `finalize_proposals` resolve that binding before idempotency lookup, require every resolvable ID in a batch to share one binding, and scope the key to the resolved account or BrandKey. Callers do not repeat identity data on those mutation requests, and sellers MUST return the same not-found response for an absent proposal and a proposal outside the caller's principal/account scope. When no ID in a batch resolves, sellers use a principal-local negative-lookup scope; this permits safe replay of an all-`unable` refinement without creating a cross-account oracle. This section applies only to AdCP task requests. OpenRTB bid streams have their own semantics (`BidRequest.id` is a transaction ID, not an idempotency key) and are out of scope. @@ -575,7 +577,7 @@ When in doubt, ask whether the buyer's intent is **"give me the same answer as b **Bootstrap carve-out — `get_adcp_capabilities`.** The discovery call itself is exempt from any schema-level key requirement. `get_adcp_capabilities` is how the buyer learns whether the seller declares `adcp.idempotency.replay_ttl_seconds`, so a fail-closed rule against the discovery call would deadlock the bootstrap. Buyers MAY omit `idempotency_key` on `get_adcp_capabilities`, and sellers MUST accept the call without it. Buyers that send `idempotency_key` on `get_adcp_capabilities` (e.g., SDKs that include the field uniformly) get the standard cache behavior — but the discovery call carries no state and replay is harmless. Every task request that carries a key remains subject to rules 1–9; the fail-closed obligation below applies to retry-sensitive operations once the capability fetch has completed. -**When the seller's capability declaration is missing.** A seller whose `get_adcp_capabilities` response omits `adcp.idempotency.replay_ttl_seconds` is non-compliant. After a successful capability fetch, client SDKs MUST fail closed before any state-mutating task — including `recommend_products`, `refine_proposal`, and `finalize_proposals` — so the buyer learns about the non-compliance before a silent duplicate. A guaranteed pure-read task whose schema leaves the key optional MAY proceed without replay protection, but the client MUST NOT silently treat a retry as deduplicated. +**When the seller's capability declaration is missing.** A seller whose `get_adcp_capabilities` response omits `adcp.idempotency.replay_ttl_seconds` is non-compliant. After a successful capability fetch, client SDKs MUST fail closed before any state-mutating task — including `request_proposals`, `refine_proposals`, and `finalize_proposals` — so the buyer learns about the non-compliance before a silent duplicate. A guaranteed pure-read task whose schema leaves the key optional MAY proceed without replay protection, but the client MUST NOT silently treat a retry as deduplicated. **Decoding seller-emitted error codes.** Sellers MAY return error codes (`IDEMPOTENCY_CONFLICT`, `IDEMPOTENCY_EXPIRED`, `IDEMPOTENCY_IN_FLIGHT`, `INVALID_REQUEST`, or codes added in later minor versions) that buyers' pinned vocabulary may not recognize. Receivers MUST decode these per [Forward-compatible decoding (normative)](/docs/building/by-layer/L3/error-handling#forward-compatible-decoding-normative) — read `error.recovery` for the recovery classification, default to `transient` when `recovery` is absent, and never reject the response because the code value is unfamiliar. The retry semantics for `transient`-classified errors are bounded by [§ Retry Logic](/docs/building/by-layer/L3/error-handling#retry-logic) (`maxRetries` and exponential backoff with jitter) — buyers MUST NOT loop indefinitely on a `transient` default. diff --git a/docs/building/by-layer/L3/error-handling.mdx b/docs/building/by-layer/L3/error-handling.mdx index f76de780b4..dd75610931 100644 --- a/docs/building/by-layer/L3/error-handling.mdx +++ b/docs/building/by-layer/L3/error-handling.mdx @@ -493,8 +493,8 @@ The wire-level `recovery: "correctable"` on the sandbox-only path is the registe |------|----------|-------------|------------| | `PRODUCT_NOT_FOUND` | correctable | Referenced product IDs are unknown or expired | Remove invalid IDs, or re-discover with `get_products` | | `PRODUCT_UNAVAILABLE` | correctable | Product is sold out or no longer available | Choose a different product | -| `PROPOSAL_EXPIRED` | correctable | Referenced proposal has passed its `expires_at` | Run `get_products` to get a fresh proposal | -| `PROPOSAL_NOT_FOUND` | correctable | `proposal_id` is unknown to the seller (never finalized, wrong tenant, or evicted from cache) | Re-issue `get_products` with `buying_mode: "refine"` + `action: "finalize"` to obtain a current proposal_id | +| `PROPOSAL_EXPIRED` | correctable | Referenced proposal has passed its `expires_at` | Call `request_proposals`, then `finalize_proposals`, to get a fresh committed proposal | +| `PROPOSAL_NOT_FOUND` | correctable | `proposal_id` is unknown to the seller (never finalized, wrong tenant, or evicted from cache) | Request and finalize a current proposal_id, then retry | | `MULTI_FINALIZE_UNSUPPORTED` | correctable | `refine[]` carried multiple `action: "finalize"` entries; seller cannot guarantee atomic multi-proposal commit | Sequence single-proposal finalize calls (one finalize per `get_products` call) | | `REQUOTE_REQUIRED` | correctable | Requested update falls outside the envelope (budget, dates, volume, targeting) the original quote was priced against; `pricing_option` remains locked | Adjust the update to fit the current quote, rediscover products/terms, add packages when available, or create a separate media buy. 3.1 does not define an amendment-quote artifact for `update_media_buy`. | | `SIGNAL_NOT_FOUND` | correctable | Referenced signal does not exist in the catalog | Verify `signal_id` via `get_signals`, or confirm availability from this agent | diff --git a/docs/media-buy/product-discovery/media-products.mdx b/docs/media-buy/product-discovery/media-products.mdx index bb4a28b5ab..05fc706495 100644 --- a/docs/media-buy/product-discovery/media-products.mdx +++ b/docs/media-buy/product-discovery/media-products.mdx @@ -1144,7 +1144,7 @@ To execute a committed proposal, provide the `proposal_id` and `total_budget` in For a fixed proposal, the publisher converts allocation percentages into packages: - `ch_desktop_de`: 20% × \$50,000 = \$10,000 -Finalization is the seller commitment step: it firms pricing, terms, availability, and any inventory hold. It is not buyer acceptance. `create_media_buy(proposal_id)` is the acceptance/execution step. Sellers reject attempts to execute a draft proposal with `PROPOSAL_NOT_COMMITTED`; finalize with `get_products` in `refine` mode and `action: "finalize"` before retrying create. +Finalization is the seller commitment step: it firms pricing, terms, availability, and any inventory hold. It is not buyer acceptance. `create_media_buy(proposal_id)` is the acceptance/execution step. Sellers reject attempts to execute a draft proposal with `PROPOSAL_NOT_COMMITTED`; call `finalize_proposals` before retrying create. The legacy `get_products` finalize form remains supported throughout 3.x. - `ch_desktop_fr`: 30% × \$50,000 = \$15,000 - etc. diff --git a/docs/media-buy/specification.mdx b/docs/media-buy/specification.mdx index 6211cf2e68..db6989f9ec 100644 --- a/docs/media-buy/specification.mdx +++ b/docs/media-buy/specification.mdx @@ -548,13 +548,13 @@ Sales agents SHOULD target the following response times: ### Idempotency -Every state-mutating AdCP task request MUST carry an `idempotency_key`. In AdCP 3.2, product discovery uses explicit contracts: `recommend_products`, `refine_proposal`, and `finalize_proposals` require a key; `list_products` and the legacy 3.x `get_products` facade leave it optional. Sales agents MUST apply the replay contract whenever a key is supplied: +Every state-mutating AdCP task request MUST carry an `idempotency_key`. In AdCP 3.2, product discovery uses explicit contracts: `request_proposals`, `refine_proposals`, and `finalize_proposals` require a key; `list_products` and the legacy 3.x `get_products` facade leave it optional. Sales agents MUST apply the replay contract whenever a key is supplied: - The same key and equivalent canonical payload within the replay window MUST return the original response without re-executing the request. - The same key with a different canonical payload MUST return `IDEMPOTENCY_CONFLICT`. - Keys are scoped to the authenticated caller and account so retries cannot cross tenant boundaries. -During 3.x, split and legacy calls are aliases in one logical operation family. Sellers normalize them before authorization, idempotency, task recovery, persistence, and webhook emission, so a retry may safely switch names. New buyers SHOULD use the split tools; sellers MUST retain `get_products` through 3.x. See [Request safety — idempotency](/docs/building/by-layer/L1/security#idempotency) for the normative cache, replay, and concurrency rules. +The compact tasks use the same product-discovery authorization policy as the legacy facade, but each stateful task has a distinct idempotency identity. A retry MUST use the same tool name and payload; changing names is a different logical request. Sellers retain `get_products` through 3.x for compatibility. See [Request safety — idempotency](/docs/building/by-layer/L1/security#idempotency) for the normative cache, replay, and concurrency rules. ### Human-in-the-Loop diff --git a/docs/media-buy/task-reference/create_media_buy.mdx b/docs/media-buy/task-reference/create_media_buy.mdx index 1035f7e462..1a9f8b91b3 100644 --- a/docs/media-buy/task-reference/create_media_buy.mdx +++ b/docs/media-buy/task-reference/create_media_buy.mdx @@ -141,7 +141,7 @@ npx @adcp/sdk@latest \ | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `account` | [account-ref](/docs/building/by-layer/L2/accounts-and-agents#account-references) | Yes | Account reference. Pass `{ "account_id": "..." }` or `{ "brand": {...}, "operator": "..." }` if the seller supports implicit resolution. Required for billing and policy evaluation. | -| `proposal_id` | string | No* | ID of a committed proposal from `get_products` to execute. Alternative to providing packages. Draft proposals must be finalized first; sellers reject draft proposal execution with `PROPOSAL_NOT_COMMITTED`. | +| `proposal_id` | string | No* | ID of a committed proposal from `finalize_proposals` to execute. Alternative to providing packages. Draft proposals must be finalized first; sellers reject draft proposal execution with `PROPOSAL_NOT_COMMITTED`. The legacy `get_products` finalize form remains supported throughout 3.x. | | `total_budget` | TotalBudget | No* | Hard aggregate lifetime budget. Its currency is the single media-buy denomination for package constraints and canonical bidding. Required for proposals and seller-optimized explicit packages. Optional in fixed explicit-package mode; when supplied there, it must equal the sum of package budgets. | | `budget_allocation` | BudgetAllocation | No | Cross-package allocation mode. Omit for fixed allocation. Use `seller_optimized` with media-buy optimization goals to delegate allocation to the seller. Must be omitted when executing a proposal because the committed proposal supplies it. | | `packages` | Package[] | No* | Array of package configurations (see below). Required when not using proposal_id. | @@ -159,7 +159,7 @@ npx @adcp/sdk@latest \ \* Either `packages` OR (`proposal_id` + `total_budget`) must be provided. -When executing a proposal, `proposal_status` on the returned proposal determines whether `create_media_buy` is valid. `committed` proposals can be executed before `expires_at`; `draft` proposals require a prior `get_products` refine call with `action: "finalize"`. Finalization is seller commitment to firm terms, not buyer acceptance. This `create_media_buy` call is the acceptance/execution step. +When executing a proposal, `proposal_status` on the returned proposal determines whether `create_media_buy` is valid. `committed` proposals can be executed before `expires_at`; `draft` proposals require a prior `finalize_proposals` call. Finalization is seller commitment to firm terms, not buyer acceptance. This `create_media_buy` call is the acceptance/execution step. The legacy `get_products` finalize form remains supported throughout 3.x. ### TotalBudget Object @@ -172,7 +172,7 @@ When executing a proposal, `proposal_status` on the returned proposal determines | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `product_id` | string | Yes | Product ID from `get_products`. Sellers MUST echo this value on every response package object that represents this requested package. | +| `product_id` | string | Yes | Product ID from `list_products` or a proposal response's supporting `products`. Sellers MUST echo this value on every response package object that represents this requested package. The legacy `get_products` source remains valid throughout 3.x. | | `pricing_option_id` | string | Yes | Pricing option ID from product's `pricing_options` array | | `format_option_refs` | FormatOptionRef[] | No | Canonical 3.2 selector into the product's `format_options[]`: publisher-scoped or product-scoped. | | `format_kind` | CanonicalFormatKind | No | Direct canonical selector. Pair with enough `params` to satisfy the product declaration. | @@ -888,7 +888,7 @@ asyncio.run(create_with_reporting()) ### Executing a Proposal -Execute a proposal from `get_products` without manually constructing packages: +Execute a committed proposal from `finalize_proposals` without manually constructing packages: @@ -901,7 +901,7 @@ const endDate = new Date(); endDate.setDate(endDate.getDate() + 90); const result = await testAgent.createMediaBuy({ - proposal_id: 'swiss_balanced_v1', // From get_products response + proposal_id: 'swiss_balanced_v1', // From finalize_proposals response total_budget: { amount: 50000, currency: 'USD' @@ -940,7 +940,7 @@ async def execute_proposal(): end_date = datetime.now(timezone.utc) + timedelta(days=90) result = await test_agent.simple.create_media_buy( - proposal_id='swiss_balanced_v1', # From get_products response + proposal_id='swiss_balanced_v1', # From finalize_proposals response total_budget={ 'amount': 50000, 'currency': 'USD' diff --git a/docs/media-buy/task-reference/finalize_proposals.mdx b/docs/media-buy/task-reference/finalize_proposals.mdx index 1232a9c9ad..b3d3c21b87 100644 --- a/docs/media-buy/task-reference/finalize_proposals.mdx +++ b/docs/media-buy/task-reference/finalize_proposals.mdx @@ -5,9 +5,10 @@ description: "Atomically commit one or more draft product proposals." testable: true --- -`finalize_proposals` is the explicit AdCP 3.2 proposal commit boundary. `idempotency_key` is required, and `proposal_ids` must be non-empty and unique. +`finalize_proposals` is the explicit proposal commit boundary. `idempotency_key` is required, and `proposal_ids` must be non-empty and unique. The request carries no brand, account, catalog, or targeting state: each opaque proposal ID is resolved under the authenticated principal and already binds that state. -**Request schema:** [`/schemas/v3/media-buy/finalize-proposals-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/finalize-proposals-request.json) + +**Request schema:** [`/schemas/latest/media-buy/finalize-proposals-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/finalize-proposals-request.json) ```json { @@ -16,4 +17,4 @@ testable: true } ``` -Multi-proposal finalization retains the legacy atomic pre-commit validation rule: if any proposal is invalid, the seller rejects the request without committing any named proposal. During 3.x, it shares replay identity with the equivalent exclusive legacy finalize array. +The seller advertises `max_atomic_finalize_batch_size`. Within that bound, finalization is all-or-nothing: every named proposal is persisted as committed, or none is. Retrying an already successful request returns the original committed snapshots and does not extend their inventory holds. diff --git a/docs/media-buy/task-reference/get_products.mdx b/docs/media-buy/task-reference/get_products.mdx index 5d8dcff29b..60dc5b8724 100644 --- a/docs/media-buy/task-reference/get_products.mdx +++ b/docs/media-buy/task-reference/get_products.mdx @@ -8,7 +8,7 @@ testable: true Discover available advertising products based on campaign requirements using natural language briefs or structured filters. -`get_products` is deprecated for new integrations in AdCP 3.2, but remains fully supported throughout 3.x. Use [`list_products`](/docs/media-buy/task-reference/list_products), [`recommend_products`](/docs/media-buy/task-reference/recommend_products), [`refine_proposal`](/docs/media-buy/task-reference/refine_proposal), or [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals). Its `idempotency_key` remains optional; when supplied, retries share the same replay identity as the equivalent split call. +`get_products` is deprecated for new integrations in AdCP 3.2, but remains fully supported throughout 3.x. Use [`list_products`](/docs/media-buy/task-reference/list_products), [`request_proposals`](/docs/media-buy/task-reference/request_proposals), [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals), or [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals). Its `idempotency_key` remains optional. Split-task retries keep the same tool name; they are not cross-name replays of this compatibility facade. diff --git a/docs/media-buy/task-reference/list_products.mdx b/docs/media-buy/task-reference/list_products.mdx index c89bc6f522..3a73a53076 100644 --- a/docs/media-buy/task-reference/list_products.mdx +++ b/docs/media-buy/task-reference/list_products.mdx @@ -1,23 +1,28 @@ --- title: list_products -description: "List the seller's synchronous wholesale product feed." +description: "Read seller product offers with structured discovery criteria." "og:title": "AdCP list_products Task" testable: true --- -`list_products` is the AdCP 3.2 side-effect-free replacement for `get_products` with `buying_mode: "wholesale"`. It never allocates an asynchronous task. `idempotency_key` is optional; when supplied, the seller applies the normal replay contract. +`list_products` is the side-effect-free entry point to the compact product lifecycle. It returns products only—never proposals—and always completes synchronously. Buyers can purchase a listed product directly or pass returned `product_id` values to `request_proposals` for seller planning. -**Request schema:** [`/schemas/v3/media-buy/list-products-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/list-products-request.json) + +**Request schema:** [`/schemas/latest/media-buy/list-products-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/list-products-request.json) ```json { - "idempotency_key": "550e8400-e29b-41d4-a716-446655441000", - "account": { - "brand": { "domain": "acmeoutdoor.example" }, - "operator": "pinnacle-agency.example" + "brand": { "domain": "acmeoutdoor.example" }, + "criteria": { + "offer_filters": { + "channels": ["olv"], + "pricing_currencies": ["USD"] + } }, - "pagination": { "max_results": 25 } + "max_results": 25 } ``` -During 3.x, this request is canonically equivalent to the same keyed `get_products` request with `buying_mode: "wholesale"`. Retrying under either name returns the original result. +`brand` is an identity-only key. Sellers resolve its canonical manifest from the domain; callers do not send logos, colors, or governance overrides in this request. Catalog ingestion belongs to `sync_catalogs`; discovery accepts only a compact catalog selection under `criteria.catalog`. + +The compact criteria deliberately contain only offer characteristics, catalog selection, and policy requirements. The targeting-aware discovery contract is being developed separately in [PR #6203](https://github.com/adcontextprotocol/adcp/pull/6203), so this task does not advertise targeting fields before sellers can enforce them. diff --git a/docs/media-buy/task-reference/recommend_products.mdx b/docs/media-buy/task-reference/recommend_products.mdx deleted file mode 100644 index ac15e131cc..0000000000 --- a/docs/media-buy/task-reference/recommend_products.mdx +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: recommend_products -description: "Request seller-curated products from a campaign brief." -"og:title": "AdCP recommend_products Task" -testable: true ---- - -`recommend_products` is the AdCP 3.2 replacement for `get_products` with `buying_mode: "brief"`. It may allocate an asynchronous task, so `idempotency_key` is required. - -**Request schema:** [`/schemas/v3/media-buy/recommend-products-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/recommend-products-request.json) - -```json -{ - "idempotency_key": "550e8400-e29b-41d4-a716-446655441001", - "brief": "Reach outdoor enthusiasts with premium video inventory.", - "account": { - "brand": { "domain": "acmeoutdoor.example" }, - "operator": "pinnacle-agency.example" - } -} -``` - -During 3.x, keyed retries are equivalent across `recommend_products` and the matching legacy `get_products` call. diff --git a/docs/media-buy/task-reference/refine_proposal.mdx b/docs/media-buy/task-reference/refine_proposal.mdx deleted file mode 100644 index 4ccbe6b2d9..0000000000 --- a/docs/media-buy/task-reference/refine_proposal.mdx +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: refine_proposal -description: "Refine one draft proposal without committing the proposal." -"og:title": "AdCP refine_proposal Task" -testable: true ---- - -`refine_proposal` preserves the established `refine[]` grammar while making the non-commit boundary explicit. `idempotency_key` is required. At least one entry must be proposal-scoped, and every proposal-scoped entry must name the same `proposal_id`. `action: "finalize"` is invalid; use `finalize_proposals`. - -**Request schema:** [`/schemas/v3/media-buy/refine-proposal-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/refine-proposal-request.json) - -```json -{ - "idempotency_key": "550e8400-e29b-41d4-a716-446655441002", - "refine": [ - { - "scope": "proposal", - "proposal_id": "proposal_123", - "action": "include", - "ask": "Shift more budget toward video." - } - ] -} -``` - -Request- and product-scoped changes may accompany the proposal change. The seller normalizes the request to the equivalent non-finalizing `get_products` refine call. diff --git a/docs/media-buy/task-reference/refine_proposals.mdx b/docs/media-buy/task-reference/refine_proposals.mdx new file mode 100644 index 0000000000..24d3db3613 --- /dev/null +++ b/docs/media-buy/task-reference/refine_proposals.mdx @@ -0,0 +1,29 @@ +--- +title: refine_proposals +description: "Create revised drafts from one or more proposals." +"og:title": "AdCP refine_proposals Task" +testable: true +--- + +`refine_proposals` creates revised drafts without committing them. Each entry targets one proposal, proposal IDs must be unique within the request, and results preserve request order. Successful refinement returns a new `proposal_id`; the source proposal remains addressable for comparison or parallel exploration. + + +**Request schema:** [`/schemas/latest/media-buy/refine-proposals-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/refine-proposals-request.json) + +```json +{ + "idempotency_key": "550e8400-e29b-41d4-a716-446655441002", + "refinements": [ + { + "proposal_id": "proposal_123", + "instructions": "Shift more budget toward video." + }, + { + "proposal_id": "proposal_456", + "instructions": "Limit the revision to inventory available in the US and Canada." + } + ] +} +``` + +The response contains one ordered `results[]` entry per request. `revised` means the requested change was applied, `partial` returns an honest draft plus explanatory notes, and `unable` returns a reason without minting a misleading revision. Refinement never commits pricing or inventory; use `finalize_proposals` when revisions have converged. diff --git a/docs/media-buy/task-reference/request_proposals.mdx b/docs/media-buy/task-reference/request_proposals.mdx new file mode 100644 index 0000000000..f06612a3ed --- /dev/null +++ b/docs/media-buy/task-reference/request_proposals.mdx @@ -0,0 +1,26 @@ +--- +title: request_proposals +description: "Request one or more actionable seller-authored media plans." +"og:title": "AdCP request_proposals Task" +testable: true +--- + +`request_proposals` creates one or more draft media-plan proposals from a brief. It can begin a consultative workflow directly or use `product_ids` returned by `list_products`. Success always contains at least one proposal; returning products without a proposal does not satisfy this task. + +`brand` is required and contains only the stable BrandKey (`domain` plus optional `brand_id`); the seller resolves the canonical brand manifest rather than receiving a full brand file in every call. `account_id` is optional and adds seller-specific commercial terms when the buyer already knows its account. + + +**Request schema:** [`/schemas/latest/media-buy/request-proposals-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/request-proposals-request.json) + +```json +{ + "idempotency_key": "550e8400-e29b-41d4-a716-446655441001", + "brief": "Reach outdoor enthusiasts with premium video inventory.", + "brand": { "domain": "acmeoutdoor.example" }, + "criteria": { + "product_ids": ["product_premium_video"] + } +} +``` + +Every returned allocation references the exact `product_id` whose pricing and forecast the seller used. The returned `proposal_id` is the only linkage needed by `refine_proposals`, `finalize_proposals`, and `create_media_buy`. diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index 2e599addbb..9923c6be80 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -90,7 +90,7 @@ Core AdCP protocol information: #### idempotency -Declares whether this seller honors `idempotency_key` replay protection. AdCP requires keys on mutating requests and requires sellers to accept them on pure reads when supplied. In the AdCP 3.2 product split, `recommend_products`, `refine_proposal`, and `finalize_proposals` require keys; `list_products` and the legacy 3.x `get_products` facade leave them optional. See [security.mdx § Idempotency](/docs/building/by-layer/L1/security#idempotency). Clients MUST NOT assume a default; a seller without this block is non-compliant and should be treated as unsafe for retry-sensitive operations. +Declares whether this seller honors `idempotency_key` replay protection. AdCP requires keys on mutating requests. In the compact AdCP 3.2 product lifecycle, `request_proposals`, `refine_proposals`, and `finalize_proposals` require keys; `list_products` and the legacy 3.x `get_products` facade leave the key optional. See [security.mdx § Idempotency](/docs/building/by-layer/L1/security#idempotency). Clients MUST NOT assume a default; a seller without this block is non-compliant and should be treated as unsafe for retry-sensitive operations. | Field | Type | Description | |-------|------|-------------| @@ -364,7 +364,9 @@ Media-buy protocol capabilities. Only present if `media_buy` is in `supported_pr #### product_discovery_tools -AdCP 3.2 sellers use `product_discovery_tools` to advertise any supported subset of `get_products`, `list_products`, `recommend_products`, `refine_proposal`, and `finalize_proposals`. When absent, buyers use the legacy `get_products` facade and inspect `buying_modes`. Split and legacy names share one authorization and idempotency operation family throughout 3.x. +AdCP 3.2 sellers use `product_discovery_tools` to advertise any supported subset of `get_products`, `list_products`, `request_proposals`, `refine_proposals`, and `finalize_proposals`. When absent, buyers use the legacy `get_products` facade and inspect `buying_modes`. The compact tools share authorization policy with legacy discovery, but each stateful tool has its own idempotency identity: retry with the same tool name and payload. + +Sellers advertising `finalize_proposals` also declare `max_atomic_finalize_batch_size`. A request within that bound either commits every named proposal or commits none. :::note 3.0 breaking changes The following fields have been removed from the capabilities response: diff --git a/docs/reference/release-notes.mdx b/docs/reference/release-notes.mdx index c8cfc2ee3c..308dd81397 100644 --- a/docs/reference/release-notes.mdx +++ b/docs/reference/release-notes.mdx @@ -13,13 +13,15 @@ Authoritative version-by-version release record for AdCP, with cumulative change **Status:** In development — minor release targeting the 3.2.0 milestone. Stable wire shapes remain backward compatible; explicitly experimental surfaces may carry noticed changes under the [experimental-status contract](/docs/reference/experimental-status). -### Product discovery split with cross-alias idempotency (#6115) +### Compact product and proposal lifecycle (#6115) -AdCP 3.2 adds `list_products`, `recommend_products`, `refine_proposal`, and `finalize_proposals`. These are additive aliases for the four jobs previously combined in `get_products`, which remains schema-valid and functional throughout 3.x. +AdCP 3.2 adds `list_products`, `request_proposals`, `refine_proposals`, and `finalize_proposals` as compact, task-specific contracts and retains `get_products` throughout 3.x. -- `list_products` is a synchronous read and accepts an optional `idempotency_key`. -- `recommend_products`, `refine_proposal`, and `finalize_proposals` require a key. -- Sellers normalize split and legacy calls to one `get_products` operation identity before authorization, idempotency hashing, task recovery, persistence, and webhook emission. Retrying under the equivalent alias replays the original result rather than duplicating work. +- `list_products` is a synchronous, naturally idempotent product-offer read. +- `request_proposals` creates actionable draft proposals from a brief and optional listed product IDs. +- `refine_proposals` creates immutable proposal revisions; `finalize_proposals` commits one or more revisions atomically. +- Each stateful task requires an idempotency key and retains its own replay identity. Retries use the same tool name. +- Brand input is an identity-only key, catalogs are compact selections, and offer filters are separated from exact targeting and future targeting support. - `media_buy.product_discovery_tools` advertises the split surface. Its absence means callers use `get_products` and `buying_modes`. New integrations SHOULD use the split tools. Existing integrations do not need an immediate migration: `get_products` keeps its optional-key 3.x contract, and sellers MUST honor replay protection whenever a key is supplied. diff --git a/package-lock.json b/package-lock.json index 6149e8bf15..bee6bb90c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@types/jsonwebtoken": "^9.0.10", "@workos-inc/node": "^10.9.0", "@workos-inc/widgets": "^1.15.0", + "ajv": "^8.20.0", "axios": "^1.19.0", "canonicalize": "3.0.0", "cookie-parser": "^1.4.7", @@ -76,7 +77,6 @@ "@types/ws": "^8.18.1", "@types/yauzl": "^3.4.0", "@typescript/native": "npm:typescript@~7.0.2", - "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "c8": "^11.0.0", "concurrently": "^10.0.4", diff --git a/package.json b/package.json index d4388309c5..86bc85c08e 100644 --- a/package.json +++ b/package.json @@ -167,6 +167,7 @@ "@types/jsonwebtoken": "^9.0.10", "@workos-inc/node": "^10.9.0", "@workos-inc/widgets": "^1.15.0", + "ajv": "^8.20.0", "axios": "^1.19.0", "canonicalize": "3.0.0", "cookie-parser": "^1.4.7", @@ -215,7 +216,6 @@ "@types/ws": "^8.18.1", "@types/yauzl": "^3.4.0", "@typescript/native": "npm:typescript@~7.0.2", - "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "c8": "^11.0.0", "concurrently": "^10.0.4", diff --git a/scripts/build-schemas.cjs b/scripts/build-schemas.cjs index ff9969e168..c022aaf73b 100644 --- a/scripts/build-schemas.cjs +++ b/scripts/build-schemas.cjs @@ -1324,20 +1324,38 @@ function resolveRefs(schema, sourceDir, ancestorRefs = new Set()) { for (const [key, value] of Object.entries(schema)) { if (key === '$ref' && typeof value === 'string' && value.startsWith('/schemas/')) { // Resolve the reference - const refPath = path.join(sourceDir, value.replace('/schemas/', '')); + const externalRef = value.replace('/schemas/', ''); + const hashIndex = externalRef.indexOf('#'); + const relativePath = hashIndex === -1 ? externalRef : externalRef.slice(0, hashIndex); + const fragment = hashIndex === -1 ? '' : externalRef.slice(hashIndex + 1); + const refPath = path.join(sourceDir, relativePath); + const ancestorKey = `${refPath}${fragment ? `#${fragment}` : ''}`; // Prevent infinite recursion for true circular refs (A → B → A) // But allow the same schema to be referenced from different locations - if (ancestorRefs.has(refPath)) { + if (ancestorRefs.has(ancestorKey)) { result[key] = value; // Keep as-is for circular refs continue; } try { - const refContent = JSON.parse(fs.readFileSync(refPath, 'utf8')); + let refContent = JSON.parse(fs.readFileSync(refPath, 'utf8')); + if (fragment) { + if (!fragment.startsWith('/')) throw new Error(`Unsupported schema fragment: ${value}`); + refContent = fragment + .slice(1) + .split('/') + .map(segment => segment.replace(/~1/g, '/').replace(/~0/g, '~')) + .reduce((current, segment) => { + if (!current || typeof current !== 'object' || !(segment in current)) { + throw new Error(`Schema pointer not found: ${value}`); + } + return current[segment]; + }, refContent); + } // Create a new set including this ref for the recursive call const newAncestors = new Set(ancestorRefs); - newAncestors.add(refPath); + newAncestors.add(ancestorKey); // Recursively resolve refs in the referenced schema const resolvedRef = resolveRefs(refContent, sourceDir, newAncestors); // Merge the resolved content. Drop `$schema` (only meaningful at diff --git a/scripts/error-code-drift-dispositions.json b/scripts/error-code-drift-dispositions.json index 2414fbb420..285757361d 100644 --- a/scripts/error-code-drift-dispositions.json +++ b/scripts/error-code-drift-dispositions.json @@ -154,7 +154,7 @@ "PROPOSAL_NOT_FOUND": { "disposition": "held-for-next-minor", "target_version": "3.1", - "note": "Proposal-lifecycle counterpart to PROPOSAL_EXPIRED / PROPOSAL_NOT_COMMITTED (#4043). Returned when a referenced proposal_id is unknown to the seller (never finalized, wrong tenant, or evicted from cache). Buyer-fixable: re-issue get_products in refine+finalize to obtain a current proposal_id. Wire change \u2014 held for 3.1." + "note": "Proposal-lifecycle counterpart to PROPOSAL_EXPIRED / PROPOSAL_NOT_COMMITTED (#4043). Returned when a referenced proposal_id is unknown to the seller (never finalized, wrong tenant, or evicted from cache). Buyer-fixable: request and finalize a current proposal. Wire change \u2014 held for 3.1." }, "PROVENANCE_CLAIM_CONTRADICTED": { "disposition": "held-for-next-minor", diff --git a/scripts/lint-storyboard-scoping.cjs b/scripts/lint-storyboard-scoping.cjs index eecbe2fc62..fff0384188 100644 --- a/scripts/lint-storyboard-scoping.cjs +++ b/scripts/lint-storyboard-scoping.cjs @@ -48,8 +48,8 @@ const TENANT_SCOPED_TASKS = new Set([ // Products & signals 'get_products', 'list_products', - 'recommend_products', - 'refine_proposal', + 'request_proposals', + 'refine_proposals', 'finalize_proposals', 'get_signals', 'activate_signal', diff --git a/server/src/training-agent/idempotency.ts b/server/src/training-agent/idempotency.ts index ed4e5f5f2d..5d7d758e51 100644 --- a/server/src/training-agent/idempotency.ts +++ b/server/src/training-agent/idempotency.ts @@ -71,8 +71,8 @@ export const MUTATING_TOOLS: ReadonlySet = new Set([ 'finalize_proposals', 'log_event', 'provide_performance_feedback', - 'recommend_products', - 'refine_proposal', + 'request_proposals', + 'refine_proposals', 'report_plan_outcome', 'report_usage', 'si_initiate_session', diff --git a/server/src/training-agent/source-schema.ts b/server/src/training-agent/source-schema.ts index adb91f23ff..f4d80c914a 100644 --- a/server/src/training-agent/source-schema.ts +++ b/server/src/training-agent/source-schema.ts @@ -1,10 +1,13 @@ -import { readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; +import Ajv, { type ErrorObject, type ValidateFunction } from 'ajv'; type JsonSchema = Record; const schemaRoot = join(process.cwd(), 'static/schemas/source'); const parsedSchemas = new Map(); +const sourceValidators = new Map(); +let sourceAjv: Ajv | undefined; const definitionAnnotationKeys = new Set([ '$comment', 'default', @@ -13,6 +16,7 @@ const definitionAnnotationKeys = new Set([ 'discriminator', 'example', 'examples', + 'enumDescriptions', 'readOnly', 'title', 'writeOnly', @@ -30,10 +34,44 @@ function pointerSegment(value: string): string { return value.replaceAll('~', '~0').replaceAll('/', '~1'); } +function resolveJsonPointer(document: JsonSchema, fragment: string): unknown { + if (!fragment) return document; + if (!fragment.startsWith('/')) { + throw new Error(`Unsupported non-pointer schema fragment: #${fragment}`); + } + return fragment + .slice(1) + .split('/') + .map(segment => segment.replaceAll('~1', '/').replaceAll('~0', '~')) + .reduce((current, segment) => { + if (!current || typeof current !== 'object' || !(segment in current)) { + throw new Error(`Schema pointer not found: #${fragment}`); + } + return (current as JsonSchema)[segment]; + }, document); +} + +function splitSchemaUri(uri: string): { relativePath: string; fragment: string } | undefined { + if (!uri.startsWith('/schemas/')) return undefined; + const external = uri.slice('/schemas/'.length); + const hashIndex = external.indexOf('#'); + return { + relativePath: hashIndex === -1 ? external : external.slice(0, hashIndex), + fragment: hashIndex === -1 ? '' : external.slice(hashIndex + 1), + }; +} + +function linkedSchemaType(uri: string): unknown { + const location = splitSchemaUri(uri); + if (!location) return undefined; + const linked = resolveJsonPointer(readSchema(location.relativePath), location.fragment); + return linked && typeof linked === 'object' ? (linked as JsonSchema).type : undefined; +} + function keepDefinitionKey(key: string): boolean { return key !== '$id' && key !== '$schema' - && !key.startsWith('x-') + && (key === 'x-adcp-schema-uri' || !key.startsWith('x-')) && !definitionAnnotationKeys.has(key); } @@ -43,16 +81,22 @@ function keepDefinitionKey(key: string): boolean { function bundleSchema(root: JsonSchema): JsonSchema { const definitions = new Map(); + function definitionKey(relativePath: string, fragment = ''): string { + return `${relativePath}${fragment ? `#${fragment}` : ''}`; + } + function definitionRef(relativePath: string, fragment = ''): string { - return `#/$defs/${pointerSegment(relativePath)}${fragment}`; + return `#/$defs/${pointerSegment(definitionKey(relativePath, fragment))}`; } - function ensureDefinition(relativePath: string): void { - if (definitions.has(relativePath)) return; + function ensureDefinition(relativePath: string, fragment = ''): void { + const key = definitionKey(relativePath, fragment); + if (definitions.has(key)) return; // Reserve the entry before descending so recursive schema graphs terminate. - definitions.set(relativePath, {}); - definitions.set(relativePath, rewrite(readSchema(relativePath), relativePath) as JsonSchema); + definitions.set(key, {}); + const source = resolveJsonPointer(readSchema(relativePath), fragment); + definitions.set(key, rewrite(source, relativePath) as JsonSchema); } function rewrite(value: unknown, definitionPath?: string): unknown { @@ -60,6 +104,16 @@ function bundleSchema(root: JsonSchema): JsonSchema { if (!value || typeof value !== 'object') return value; const record = value as JsonSchema; + const linkedSchemaUri = record['x-adcp-schema-uri']; + if (typeof linkedSchemaUri === 'string') { + const type = record.type ?? linkedSchemaType(linkedSchemaUri) ?? 'object'; + return { + type, + ...(typeof record.description === 'string' && { description: record.description }), + 'x-adcp-schema-uri': linkedSchemaUri, + ...(type === 'object' && { additionalProperties: true }), + }; + } const ref = record.$ref; if (typeof ref === 'string') { let rewrittenRef = ref; @@ -71,14 +125,15 @@ function bundleSchema(root: JsonSchema): JsonSchema { if (fragment && !fragment.startsWith('/')) { throw new Error(`Unsupported non-pointer schema fragment: ${ref}`); } - ensureDefinition(relativePath); + ensureDefinition(relativePath, fragment); rewrittenRef = definitionRef(relativePath, fragment); } else if (definitionPath && ref.startsWith('#')) { const fragment = ref.slice(1); if (fragment && !fragment.startsWith('/')) { throw new Error(`Unsupported non-pointer local schema fragment: ${ref}`); } - rewrittenRef = definitionRef(definitionPath, fragment); + ensureDefinition(definitionPath); + rewrittenRef = `#/$defs/${pointerSegment(definitionPath)}${fragment}`; } const siblings = Object.fromEntries( @@ -114,3 +169,52 @@ function bundleSchema(root: JsonSchema): JsonSchema { export function loadProductDiscoveryInputSchema(fileName: string): JsonSchema { return bundleSchema(readSchema(`media-buy/${fileName}.json`)); } + +function schemaFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return schemaFiles(path); + return entry.name.endsWith('.json') ? [path] : []; + }); +} + +function productDiscoverySourceValidator(fileName: string): ValidateFunction { + const cached = sourceValidators.get(fileName); + if (cached) return cached; + + if (!sourceAjv) { + sourceAjv = new Ajv({ strict: false, validateFormats: false }); + for (const path of schemaFiles(schemaRoot)) { + const schema = JSON.parse(readFileSync(path, 'utf8')) as JsonSchema; + if (typeof schema.$id === 'string') sourceAjv.addSchema(schema, schema.$id); + } + } + const validator = sourceAjv.getSchema(`/schemas/media-buy/${fileName}.json`); + if (!validator) throw new Error(`Source schema validator not found: ${fileName}`); + sourceValidators.set(fileName, validator); + return validator; +} + +function errorField(error: ErrorObject): string | undefined { + const path = error.instancePath.replace(/^\//, '').replaceAll('/', '.'); + if (path) return path; + const missing = (error.params as { missingProperty?: unknown }).missingProperty; + return typeof missing === 'string' ? missing : undefined; +} + +/** Validate the actual split-tool call against the normative source schema. + * MCP tools/list intentionally projects large linked objects to compact type + * hints, so dispatch must still enforce the complete canonical contract. */ +export function validateProductDiscoverySourceInput( + fileName: string, + args: Record, +): { message: string; field?: string } | undefined { + const validator = productDiscoverySourceValidator(fileName); + if (validator(args)) return undefined; + const error = validator.errors?.[0]; + const field = error && errorField(error); + return { + message: `Invalid ${fileName.replaceAll('-', '_')}${field ? ` at ${field}` : ''}: ${error?.message ?? 'schema validation failed'}`, + ...(field && { field }), + }; +} diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 6ad79ce8f0..8315da7ab2 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -1727,7 +1727,10 @@ import { resetTrainingTaskStore, type TrainingTaskStore, } from './mcp-task-store.js'; -import { loadProductDiscoveryInputSchema } from './source-schema.js'; +import { + loadProductDiscoveryInputSchema, + validateProductDiscoverySourceInput, +} from './source-schema.js'; const SUPPORTED_MAJOR_VERSIONS = [3] as const; const SUPPORTED_RELEASE_VERSIONS = ['3.0', '3.1-beta.5', '3.1-beta.7', '3.1-rc.4', '3.1-rc.6', '3.1-rc.7', '3.1-rc.8', '3.1-rc.9', '3.1-rc.10', '3.1-rc.14', '3.1-rc.15', GET_PRODUCTS_REJECTED_ADCP_VERSION] as const; @@ -2282,10 +2285,52 @@ function deriveAccountScope(args: Record, strictAccountRef = tr return undefined; } } - const domain = (args.brand as { domain?: unknown } | undefined)?.domain; - return typeof domain === 'string' && domain.length > 0 - ? `b:${domain.toLowerCase()}` - : undefined; + if (typeof args.account_id === 'string' && args.account_id.length > 0) { + return `a:${args.account_id}`; + } + return compactBrandScope(args.brand); +} + +async function deriveProductDiscoveryAccountScope( + toolName: string, + originalArgs: Record, + normalizedArgs: Record, + ctx: TrainingContext, +): Promise { + const directScope = deriveAccountScope(normalizedArgs, isProductDiscoveryTool(toolName)); + if (directScope || (toolName !== 'refine_proposals' && toolName !== 'finalize_proposals')) { + return directScope; + } + + const proposalIds = toolName === 'refine_proposals' && Array.isArray(originalArgs.refinements) + ? originalArgs.refinements + .filter(isRecord) + .map(refinement => refinement.proposal_id) + .filter((id): id is string => typeof id === 'string') + : Array.isArray(originalArgs.proposal_ids) + ? originalArgs.proposal_ids.filter((id): id is string => typeof id === 'string') + : []; + const proposalSession = await getSession( + sessionKeyFromArgs({}, ctx.mode, ctx.userId, ctx.moduleId, ctx.principal ?? 'anonymous'), + ); + const proposalsById = new Map( + (proposalSession.lastGetProductsContext?.proposals ?? []).map(proposal => [proposal.proposal_id, proposal]), + ); + const scopes = new Set(); + for (const proposalId of proposalIds) { + const internal = proposalsById.get(proposalId) as unknown as Record | undefined; + if (typeof internal?.__account_id === 'string') scopes.add(`a:${internal.__account_id}`); + else if (typeof internal?.__brand_domain === 'string') { + scopes.add(compactBrandScope({ + domain: internal.__brand_domain, + ...(typeof internal.__brand_id === 'string' && { brand_id: internal.__brand_id }), + })!); + } + } + if (scopes.size > 1) { + throw new AccountRefValidationError('All proposal IDs in one lifecycle request must resolve to the same account.'); + } + return scopes.values().next().value ?? 'proposal-lookup:unresolved'; } /** @@ -4091,8 +4136,8 @@ function signalMatchesRef( const PRODUCT_DISCOVERY_TOOLS = new Set([ 'get_products', 'list_products', - 'recommend_products', - 'refine_proposal', + 'request_proposals', + 'refine_proposals', 'finalize_proposals', ]); @@ -4100,31 +4145,120 @@ function isProductDiscoveryTool(toolName: string): boolean { return PRODUCT_DISCOVERY_TOOLS.has(toolName); } +function compactBrandScope(brand: unknown): string | undefined { + if (!isRecord(brand) || typeof brand.domain !== 'string' || brand.domain.length === 0) return undefined; + const brandId = typeof brand.brand_id === 'string' ? brand.brand_id : ''; + return `b:${brand.domain.toLowerCase()}#${brandId}`; +} + +function productDiscoverySourceSchemaName(toolName: string): string | undefined { + switch (toolName) { + case 'list_products': return 'list-products-request'; + case 'request_proposals': return 'request-proposals-request'; + case 'refine_proposals': return 'refine-proposals-request'; + case 'finalize_proposals': return 'finalize-proposals-request'; + default: return undefined; + } +} + export function canonicalProductDiscoveryTool(toolName: string): string { return isProductDiscoveryTool(toolName) ? 'get_products' : toolName; } -/** - * Normalize every AdCP 3.2 split product task to the stable 3.x get_products - * logical payload before validation, idempotency, task recovery, and handler - * dispatch. This makes a retry safe even when a caller switches between the - * legacy facade and its canonical 3.2 alias after an ambiguous failure. - */ +function expandProductDiscoveryCriteria(criteria: unknown): Record { + if (!isRecord(criteria)) return {}; + const { + offer_filters: filters, + policy_ids: requiredPolicies, + ...rest + } = criteria; + return { + ...rest, + ...(filters !== undefined && { filters }), + ...(requiredPolicies !== undefined && { required_policies: requiredPolicies }), + }; +} + +function expandProductDiscoveryIdentity(args: Record): Record { + const { account_id: accountId, ...rest } = args; + return { + ...rest, + ...(typeof accountId === 'string' && { account: { account_id: accountId } }), + }; +} + +/** Envelope fields are accepted uniformly on every AdCP call but are not part + * of the legacy get_products domain payload. Dispatch retains the original + * wire args for source validation and idempotency equivalence. */ +function stripProductDiscoveryEnvelope(args: Record): Record { + const { + context: _context, + context_id: _contextId, + governance_context: _governanceContext, + push_notification_config: _pushNotificationConfig, + ...domainArgs + } = args; + return domainArgs; +} + +/** Normalize the compact 3.2 tools into the legacy get_products handler shape. + * The split operations retain distinct idempotency identities and project + * task-specific responses; this adapter exists only for 3.x implementation + * reuse. */ export function normalizeProductDiscoveryArgs( toolName: string, args: Record, ): Record { + const domainArgs = isProductDiscoveryTool(toolName) + ? stripProductDiscoveryEnvelope(args) + : args; if (toolName === 'list_products') { - const { buying_mode: _buyingMode, brief: _brief, refine: _refine, ...rest } = args; - return { ...rest, buying_mode: 'wholesale' }; + const { + criteria, + cursor, + max_results: maxResults, + if_feed_version: ifFeedVersion, + ...rest + } = expandProductDiscoveryIdentity(domainArgs); + return { + ...rest, + ...expandProductDiscoveryCriteria(criteria), + ...(cursor !== undefined || maxResults !== undefined + ? { pagination: { ...(cursor !== undefined && { cursor }), ...(maxResults !== undefined && { max_results: maxResults }) } } + : {}), + ...(ifFeedVersion !== undefined && { if_wholesale_feed_version: ifFeedVersion }), + buying_mode: 'wholesale', + }; } - if (toolName === 'recommend_products') { - const { buying_mode: _buyingMode, refine: _refine, ...rest } = args; - return { ...rest, buying_mode: 'brief' }; + if (toolName === 'request_proposals') { + const { criteria, ...rest } = expandProductDiscoveryIdentity(domainArgs); + return { + ...rest, + ...expandProductDiscoveryCriteria(criteria), + buying_mode: 'brief', + __compact_proposal_lifecycle: true, + __require_proposals: true, + }; } - if (toolName === 'refine_proposal') { - const { buying_mode: _buyingMode, ...rest } = args; - return { ...rest, buying_mode: 'refine' }; + if (toolName === 'refine_proposals') { + const { refinements, ...rest } = domainArgs; + return { + ...rest, + buying_mode: 'refine', + __compact_proposal_lifecycle: true, + __immutable_refine: true, + refine: Array.isArray(refinements) + ? refinements.map(entry => { + if (!isRecord(entry)) return entry; + return { + scope: 'proposal', + proposal_id: entry.proposal_id, + action: 'include', + ...(typeof entry.instructions === 'string' && { ask: entry.instructions }), + }; + }) + : [], + }; } if (toolName === 'finalize_proposals') { const { @@ -4132,10 +4266,11 @@ export function normalizeProductDiscoveryArgs( refine: _refine, proposal_ids: proposalIds, ...rest - } = args; + } = domainArgs; return { ...rest, buying_mode: 'refine', + __compact_proposal_lifecycle: true, refine: Array.isArray(proposalIds) ? proposalIds.map(proposalId => ({ scope: 'proposal', proposal_id: proposalId, action: 'finalize' })) : [], @@ -4144,6 +4279,170 @@ export function normalizeProductDiscoveryArgs( return args; } +function supportingProductsForProposals( + proposals: Array>, + products: Array>, +): Array> { + const referenced = new Set(); + for (const proposal of proposals) { + if (!Array.isArray(proposal.allocations)) continue; + for (const allocation of proposal.allocations) { + if (isRecord(allocation) && typeof allocation.product_id === 'string') { + referenced.add(allocation.product_id); + } + } + } + return products.filter(product => typeof product.product_id === 'string' && referenced.has(product.product_id)); +} + +function outwardProposal(proposal: Record): Record { + const { + __source_proposal_id: _sourceProposalId, + __brand_domain: _brandDomain, + __brand_id: _brandId, + __account_id: _accountId, + __refinement_outcome: _refinementOutcome, + __refinement_notes: _refinementNotes, + ...outward + } = proposal; + return outward; +} + +/** Project the broad 3.x handler result into the compact split-tool domain + * response. The idempotency store keeps the canonical result; projection is + * applied after replay lookup so each tool retains its own wire contract. */ +export function projectProductDiscoveryResult( + toolName: string, + result: Record, + originalArgs: Record, +): Record { + if (!isProductDiscoveryTool(toolName) || toolName === 'get_products') return result; + if (Array.isArray(result.errors) && result.errors.length > 0 && !Array.isArray(result.products)) return result; + if (toolName === 'request_proposals' && result.status === 'rejected') { + return { + outcome: 'rejected', + ...(typeof result.reason === 'string' && { reason: result.reason }), + ...(Array.isArray(result.suggestions) && { suggestions: result.suggestions }), + }; + } + + let products = Array.isArray(result.products) + ? result.products.filter(isRecord) + : []; + let proposals = Array.isArray(result.proposals) + ? result.proposals.filter(isRecord) + : []; + const criteria = isRecord(originalArgs.criteria) ? originalArgs.criteria : undefined; + const requestedProductIds = criteria && Array.isArray(criteria.product_ids) + ? new Set(criteria.product_ids.filter((id): id is string => typeof id === 'string')) + : undefined; + if (requestedProductIds) { + products = products.filter(product => ( + typeof product.product_id === 'string' && requestedProductIds.has(product.product_id) + )); + if (toolName === 'request_proposals') { + proposals = proposals.filter(proposal => ( + Array.isArray(proposal.allocations) + && proposal.allocations.every(allocation => ( + isRecord(allocation) + && typeof allocation.product_id === 'string' + && requestedProductIds.has(allocation.product_id) + )) + )); + } + } + + if (toolName === 'list_products') { + if (result.unchanged === true) { + return { + unchanged: true, + ...(typeof result.wholesale_feed_version === 'string' && { feed_version: result.wholesale_feed_version }), + ...(typeof result.pricing_version === 'string' && { pricing_version: result.pricing_version }), + ...(typeof result.cache_scope === 'string' && { cache_scope: result.cache_scope }), + }; + } + const pagination = isRecord(result.pagination) ? result.pagination : undefined; + return { + products, + ...(pagination && typeof pagination.cursor === 'string' && { next_cursor: pagination.cursor }), + ...(typeof result.wholesale_feed_version === 'string' && { feed_version: result.wholesale_feed_version }), + ...(typeof result.pricing_version === 'string' && { pricing_version: result.pricing_version }), + ...(typeof result.cache_scope === 'string' && { cache_scope: result.cache_scope }), + ...(Array.isArray(result.incomplete) && { incomplete: result.incomplete }), + }; + } + + if (toolName === 'request_proposals') { + const outwardProposals = proposals.map(outwardProposal); + return { + proposals: outwardProposals, + products: supportingProductsForProposals(outwardProposals, products), + ...(isRecord(result.targeting_resolution) && { targeting_resolution: result.targeting_resolution }), + }; + } + + const requestedIds = new Set( + toolName === 'refine_proposals' && Array.isArray(originalArgs.refinements) + ? originalArgs.refinements + .filter(isRecord) + .map(entry => entry.proposal_id) + .filter((id): id is string => typeof id === 'string') + : toolName === 'finalize_proposals' && Array.isArray(originalArgs.proposal_ids) + ? originalArgs.proposal_ids.filter((id): id is string => typeof id === 'string') + : [], + ); + const selected = proposals.filter(proposal => ( + typeof proposal.proposal_id === 'string' && requestedIds.has(proposal.proposal_id) + )); + if (toolName === 'refine_proposals') { + const sourceIds = Array.isArray(originalArgs.refinements) + ? originalArgs.refinements + .filter(isRecord) + .map(entry => entry.proposal_id) + .filter((id): id is string => typeof id === 'string') + : []; + const proposalsBySource = new Map(proposals.map(proposal => [ + proposal.__source_proposal_id, + proposal, + ])); + const outwardProposals = Array.from(proposalsBySource.values()).map(outwardProposal); + return { + results: sourceIds.map(sourceProposalId => { + const internalProposal = proposalsBySource.get(sourceProposalId); + const proposal = internalProposal && outwardProposal(internalProposal); + const outcome = internalProposal?.__refinement_outcome === 'partial' ? 'partial' : 'revised'; + return proposal + ? { + source_proposal_id: sourceProposalId, + outcome, + proposal, + ...(typeof internalProposal?.__refinement_notes === 'string' + && { notes: internalProposal.__refinement_notes }), + } + : { + source_proposal_id: sourceProposalId, + outcome: 'unable', + reason: 'The source proposal was not found or could not be revised under the requested terms.', + }; + }), + products: supportingProductsForProposals(outwardProposals, products), + }; + } + return { proposals: selected.map(outwardProposal) }; +} + +/** Compact proposal operations address proposals by opaque ID under the + * authenticated principal. They deliberately do not repeat account or brand + * on every lifecycle call. The legacy facade retains its account-derived + * session partition for 3.x compatibility. */ +function productDiscoverySessionKey(args: ToolArgs, ctx: TrainingContext): string { + const compactLifecycle = (args as unknown as Record).__compact_proposal_lifecycle === true; + if (compactLifecycle) { + return sessionKeyFromArgs({}, ctx.mode, ctx.userId, ctx.moduleId, ctx.principal ?? 'anonymous'); + } + return getProductsSessionKeyFromArgs(args, ctx.mode, ctx.userId, ctx.moduleId); +} + function idempotencyPayloadForServedVersion( toolName: string, args: Record, @@ -4157,58 +4456,101 @@ function idempotencyPayloadForServedVersion( // The legacy facade defaults to 3.0 while split names default to 3.2. // Bind an omitted caller pin to the effective release so those distinct // wire contracts conflict instead of replaying a response across versions. - return { ...args, adcp_version: servedAdcpVersion }; + return { + ...args, + adcp_version: servedAdcpVersion, + ...(toolName !== 'get_products' && { __adcp_operation: toolName }), + }; } - return args; + return toolName === 'get_products' ? args : { ...args, __adcp_operation: toolName }; } export function validateProductDiscoveryAliasInput( toolName: string, args: Record, ): { message: string; field?: string } | undefined { + const allowedFields: Record> = { + list_products: new Set([ + 'adcp_version', 'adcp_major_version', 'idempotency_key', 'context_id', + 'governance_context', 'push_notification_config', 'account_id', 'brand', 'criteria', + 'fields', 'cursor', 'max_results', 'if_feed_version', 'if_pricing_version', + ]), + request_proposals: new Set([ + 'adcp_version', 'adcp_major_version', 'idempotency_key', 'account_id', + 'context_id', 'governance_context', 'push_notification_config', + 'brand', 'brief', 'criteria', + ]), + refine_proposals: new Set([ + 'adcp_version', 'adcp_major_version', 'idempotency_key', 'refinements', + 'context_id', 'governance_context', 'push_notification_config', + ]), + finalize_proposals: new Set([ + 'adcp_version', 'adcp_major_version', 'idempotency_key', 'proposal_ids', + 'context_id', 'governance_context', 'push_notification_config', + ]), + }; + const allowed = allowedFields[toolName]; + if (allowed) { + const unknown = Object.keys(args).find(field => !allowed.has(field)); + if (unknown) return { message: `${unknown} is not supported by ${toolName}`, field: unknown }; + } if ( - (toolName === 'recommend_products' || toolName === 'refine_proposal' || toolName === 'finalize_proposals') + (toolName === 'request_proposals' || toolName === 'refine_proposals' || toolName === 'finalize_proposals') && args.idempotency_key == null ) { return { message: `idempotency_key is required for ${toolName}`, field: 'idempotency_key' }; } if (toolName === 'list_products') { - for (const field of ['buying_mode', 'brief', 'refine']) { - if (args[field] !== undefined) return { message: `${field} is not valid for list_products`, field }; + if (args.if_pricing_version !== undefined && args.if_feed_version === undefined) { + return { message: 'if_pricing_version requires if_feed_version', field: 'if_feed_version' }; + } + const criteria = isRecord(args.criteria) ? args.criteria : undefined; + if (isRecord(criteria?.catalog) && args.brand === undefined) { + return { message: 'brand is required when catalog criteria are present', field: 'brand' }; } - if (args.if_pricing_version !== undefined && args.if_wholesale_feed_version === undefined) { - return { message: 'if_pricing_version requires if_wholesale_feed_version', field: 'if_wholesale_feed_version' }; + if (isRecord(criteria?.catalog) && typeof criteria.catalog.catalog_id !== 'string') { + return { message: 'criteria.catalog.catalog_id is required', field: 'criteria.catalog.catalog_id' }; } } - if (toolName === 'recommend_products') { + if (toolName === 'request_proposals') { if (typeof args.brief !== 'string' || args.brief.length === 0) { - return { message: 'brief is required for recommend_products', field: 'brief' }; + return { message: 'brief is required for request_proposals', field: 'brief' }; } - for (const field of ['buying_mode', 'refine', 'if_wholesale_feed_version', 'if_pricing_version']) { - if (args[field] !== undefined) return { message: `${field} is not valid for recommend_products`, field }; + if (args.brand === undefined) { + return { message: 'brand is required for request_proposals', field: 'brand' }; + } + const criteria = isRecord(args.criteria) ? args.criteria : undefined; + if (isRecord(criteria?.catalog) && args.brand === undefined) { + return { message: 'brand is required when catalog criteria are present', field: 'brand' }; + } + if (isRecord(criteria?.catalog) && typeof criteria.catalog.catalog_id !== 'string') { + return { message: 'criteria.catalog.catalog_id is required', field: 'criteria.catalog.catalog_id' }; } } - if (toolName === 'refine_proposal') { - if (!Array.isArray(args.refine) || args.refine.length === 0) { - return { message: 'refine must contain at least one change', field: 'refine' }; + if (toolName === 'refine_proposals') { + if (!Array.isArray(args.refinements) || args.refinements.length === 0) { + return { message: 'refinements must contain at least one proposal change', field: 'refinements' }; } const proposalIds = new Set(); - for (let index = 0; index < args.refine.length; index += 1) { - const entry = args.refine[index]; - if (!isRecord(entry)) continue; - if (entry.action === 'finalize') { - return { message: 'action finalize is only valid on finalize_proposals', field: `refine[${index}].action` }; + for (let index = 0; index < args.refinements.length; index += 1) { + const entry = args.refinements[index]; + if (!isRecord(entry)) { + return { message: 'refinement entries must be objects', field: `refinements[${index}]` }; + } + if (typeof entry.proposal_id !== 'string' || entry.proposal_id.length === 0) { + return { message: 'proposal_id is required for every refinement', field: `refinements[${index}].proposal_id` }; + } + if (proposalIds.has(entry.proposal_id)) { + return { message: 'proposal_id values in refinements must be unique', field: `refinements[${index}].proposal_id` }; + } + proposalIds.add(entry.proposal_id); + if (!(typeof entry.instructions === 'string' && entry.instructions.length > 0)) { + return { message: 'each refinement requires instructions', field: `refinements[${index}].instructions` }; + } + const unknown = Object.keys(entry).find(field => !['proposal_id', 'instructions'].includes(field)); + if (unknown) { + return { message: `${unknown} is not supported on proposal refinements`, field: `refinements[${index}].${unknown}` }; } - if (entry.scope === 'proposal' && typeof entry.proposal_id === 'string') proposalIds.add(entry.proposal_id); - } - if (proposalIds.size === 0) { - return { message: 'refine_proposal requires at least one proposal-scoped change', field: 'refine' }; - } - if (proposalIds.size > 1) { - return { message: 'refine_proposal may target only one proposal_id', field: 'refine' }; - } - for (const field of ['buying_mode', 'brief', 'if_wholesale_feed_version', 'if_pricing_version']) { - if (args[field] !== undefined) return { message: `${field} is not valid for refine_proposal`, field }; } } if (toolName === 'finalize_proposals') { @@ -4221,36 +4563,22 @@ export function validateProductDiscoveryAliasInput( if (new Set(args.proposal_ids).size !== args.proposal_ids.length) { return { message: 'proposal_ids entries must be unique', field: 'proposal_ids' }; } - for (const field of [ - 'buying_mode', - 'brief', - 'refine', - 'catalog', - 'filters', - 'fields', - 'preferred_delivery_types', - 'property_list', - 'required_policies', - 'if_wholesale_feed_version', - 'if_pricing_version', - 'pagination', - 'time_budget', - ]) { - if (args[field] !== undefined) return { message: `${field} is not valid for finalize_proposals`, field }; + if (args.proposal_ids.length > 25) { + return { message: 'proposal_ids exceeds max_atomic_finalize_batch_size (25)', field: 'proposal_ids' }; } } return undefined; } const LIST_PRODUCTS_INPUT_SCHEMA = loadProductDiscoveryInputSchema('list-products-request'); -const RECOMMEND_PRODUCTS_INPUT_SCHEMA = loadProductDiscoveryInputSchema('recommend-products-request'); -const REFINE_PROPOSAL_INPUT_SCHEMA = loadProductDiscoveryInputSchema('refine-proposal-request'); +const REQUEST_PROPOSALS_INPUT_SCHEMA = loadProductDiscoveryInputSchema('request-proposals-request'); +const REFINE_PROPOSALS_INPUT_SCHEMA = loadProductDiscoveryInputSchema('refine-proposals-request'); const FINALIZE_PROPOSALS_INPUT_SCHEMA = loadProductDiscoveryInputSchema('finalize-proposals-request'); const TOOLS = [ { name: 'get_products', - description: 'DEPRECATED in AdCP 3.2. Compatibility facade for brief, wholesale, refine, and finalize product flows. New callers use list_products, recommend_products, refine_proposal, and finalize_proposals.', + description: 'DEPRECATED in AdCP 3.2. Compatibility facade for brief, wholesale, refine, and finalize product flows. New callers use list_products, request_proposals, refine_proposals, and finalize_proposals.', // Polymorphic: brief/wholesale can be reads, but Submitted responses // allocate a task and refine+finalize commits an inventory hold. annotations: { readOnlyHint: false, idempotentHint: true }, @@ -4286,24 +4614,24 @@ const TOOLS = [ }, { name: 'list_products', - description: 'List the synchronous wholesale product feed. This is the side-effect-free AdCP 3.2 replacement for get_products with buying_mode "wholesale".', + description: 'List product offers with structured commercial criteria. Returns products only; use request_proposals for seller-authored plans.', annotations: { readOnlyHint: true, idempotentHint: true }, execution: { taskSupport: 'forbidden' as const }, inputSchema: LIST_PRODUCTS_INPUT_SCHEMA, }, { - name: 'recommend_products', - description: 'Request curated product recommendations from a campaign brief. May complete asynchronously. AdCP 3.2 replacement for get_products with buying_mode "brief".', + name: 'request_proposals', + description: 'Request one or more actionable media-plan proposals from a brief and optional listed product IDs. Proposal IDs connect later refinement, finalization, and purchase.', annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, execution: { taskSupport: 'optional' as const }, - inputSchema: RECOMMEND_PRODUCTS_INPUT_SCHEMA, + inputSchema: REQUEST_PROPOSALS_INPUT_SCHEMA, }, { - name: 'refine_proposal', - description: 'Refine one draft proposal without committing it. AdCP 3.2 replacement for non-finalizing get_products refine calls.', + name: 'refine_proposals', + description: 'Create revised drafts from one or more proposals without committing them. Each refinement is keyed by proposal_id.', annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, execution: { taskSupport: 'optional' as const }, - inputSchema: REFINE_PROPOSAL_INPUT_SCHEMA, + inputSchema: REFINE_PROPOSALS_INPUT_SCHEMA, }, { name: 'finalize_proposals', @@ -4752,8 +5080,8 @@ const TOOLS = [ export function productDiscoveryAliasToolDefinitions(): Array<(typeof TOOLS)[number]> { return structuredClone(TOOLS.filter(tool => ( tool.name === 'list_products' - || tool.name === 'recommend_products' - || tool.name === 'refine_proposal' + || tool.name === 'request_proposals' + || tool.name === 'refine_proposals' || tool.name === 'finalize_proposals' ))); } @@ -4812,7 +5140,7 @@ export async function handleGetProducts(args: ToolArgs, ctx: TrainingContext): P } const buyingMode = req.buying_mode ?? 'brief'; - const sessionScope = getProductsSessionKeyFromArgs(args, ctx.mode, ctx.userId, ctx.moduleId); + const sessionScope = productDiscoverySessionKey(args, ctx); const sessionHash = createHash('sha256').update(sessionScope).digest('hex'); const principal = 'get-products-session-mutex'; const key = `get-products-session:${sessionHash}`; @@ -4938,7 +5266,7 @@ async function handleGetProductsUnlocked( } } const session = await getSession( - getProductsSessionKeyFromArgs(req, ctx.mode, ctx.userId, ctx.moduleId), + productDiscoverySessionKey(req, ctx), controllerFixtureSessionKey(req, ctx), ); const committedProposals = new Map( @@ -5112,10 +5440,13 @@ async function handleGetProductsUnlocked( | { scope: 'product'; product_id: string; status: 'applied' | 'partial' | 'unable'; notes?: string } | { scope: 'proposal'; proposal_id: string; status: 'applied' | 'partial' | 'unable'; notes?: string }; + const immutableRefine = buyingMode === 'refine' + && (req as unknown as Record).__immutable_refine === true; const refinementApplied: RefinementAppliedEntry[] = []; const proposalOmitIds = new Set(); const refinedProposalOverrides = new Map(); const explicitlySelectedProposals = new Map(); + const stagedProposalCommits = new Map(); let guaranteedOnlyRequested = false; if (buyingMode === 'refine' && req.refine) { const refineOps = req.refine as unknown as RefineEntry[]; @@ -5161,6 +5492,7 @@ async function handleGetProductsUnlocked( if (op.scope !== 'proposal') continue; const proposal = resolveProposal(op.proposal_id); if (!proposal) { + if (immutableRefine) continue; return { errors: [{ code: 'PROPOSAL_NOT_FOUND', @@ -5170,6 +5502,31 @@ async function handleGetProductsUnlocked( }] as TaskError[], }; } + if (op.action === 'finalize') { + if (proposal.expires_at && new Date(proposal.expires_at) < new Date()) { + return { + errors: [{ + code: 'PROPOSAL_EXPIRED', + message: `Proposal expired at ${proposal.expires_at}: ${op.proposal_id}`, + field: `refine[${opIndex}].proposal_id`, + recovery: 'correctable', + }] as TaskError[], + }; + } + const unavailableAllocation = proposal.allocations.find( + allocation => !knownProductIds.has(allocation.product_id), + ); + if (unavailableAllocation) { + return { + errors: [{ + code: 'PRODUCT_UNAVAILABLE', + message: `Proposal ${op.proposal_id} references unavailable product ${unavailableAllocation.product_id}`, + field: `refine[${opIndex}].proposal_id`, + recovery: 'correctable', + }] as TaskError[], + }; + } + } } for (let opIndex = 0; opIndex < refineOps.length; opIndex++) { @@ -5328,7 +5685,10 @@ async function handleGetProductsUnlocked( if (hasGuaranteed) { const publisherCp = getCatalog().find(c => c.product.product_id === proposal.allocations[0].product_id); const accountBrand = (req as unknown as Record).account as Record | undefined; - const brandDomain = ((accountBrand?.brand as Record)?.domain as string) || 'advertiser.example'; + const boundBrandDomain = (proposal as unknown as Record).__brand_domain; + const brandDomain = ((accountBrand?.brand as Record)?.domain as string) + || (typeof boundBrandDomain === 'string' ? boundBrandDomain : undefined) + || 'advertiser.example'; committed.insertion_order = { io_id: `io_${randomUUID().replace(/-/g, '')}`, terms: { @@ -5346,18 +5706,8 @@ async function handleGetProductsUnlocked( }; } - if (!session.lastGetProductsContext) { - session.lastGetProductsContext = { products: [...products], proposals: [] }; - } - const sessionProposals = session.lastGetProductsContext.proposals || []; - const idx = sessionProposals.findIndex(p => p.proposal_id === op.proposal_id); const updatedProposal = committed as unknown as import('@adcp/sdk').Proposal; - if (idx >= 0) { - sessionProposals[idx] = updatedProposal; - } else { - sessionProposals.push(updatedProposal); - } - session.lastGetProductsContext.proposals = sessionProposals; + stagedProposalCommits.set(op.proposal_id, updatedProposal); refinementApplied.push({ scope: 'proposal', proposal_id: op.proposal_id, status: 'applied', notes: 'Proposal finalized — pricing committed, inventory held for 24 hours' }); } else { @@ -5400,6 +5750,21 @@ async function handleGetProductsUnlocked( if (guaranteedOnlyRequested) { products = products.filter(product => product.delivery_type === 'guaranteed'); } + if (stagedProposalCommits.size > 0) { + // Publish the complete batch with one assignment only after every + // proposal has been resolved and every committed snapshot constructed. + // This is the training agent's transaction boundary: no request can + // observe a prefix of an atomic finalize_proposals batch. + const prior = session.lastGetProductsContext?.proposals ?? []; + const next = prior.map(proposal => stagedProposalCommits.get(proposal.proposal_id) ?? proposal); + for (const [proposalId, proposal] of stagedProposalCommits) { + if (!prior.some(existing => existing.proposal_id === proposalId)) next.push(proposal); + } + session.lastGetProductsContext = { + products: session.lastGetProductsContext?.products ?? [...products], + proposals: next, + }; + } } // Brief mode only: complete proposals by pulling in missing allocated products. @@ -5436,7 +5801,7 @@ async function handleGetProductsUnlocked( ]; const productsById = new Map(products.map(p => [p.product_id, p])); - const proposals = sourceProposals + let proposals = sourceProposals .map(proposal => refinedProposalOverrides.get(proposal.proposal_id) ?? proposal) .filter(proposal => proposal.allocations.every(a => productIds.has(a.product_id)) && @@ -5459,6 +5824,88 @@ async function handleGetProductsUnlocked( }), }; }); + const requireProposals = buyingMode === 'brief' + && (req as unknown as Record).__require_proposals === true; + if (requireProposals) { + const exactProductIds = Array.isArray((req as unknown as Record).product_ids) + ? new Set(((req as unknown as Record).product_ids as unknown[]) + .filter((id): id is string => typeof id === 'string')) + : undefined; + if (exactProductIds) { + proposals = proposals.filter(proposal => proposal.allocations.every(allocation => ( + exactProductIds.has(allocation.product_id) + ))); + } + const key = typeof (req as unknown as Record).idempotency_key === 'string' + ? (req as unknown as Record).idempotency_key as string + : 'unkeyed'; + const existingById = new Map( + (session.lastGetProductsContext?.proposals ?? []).map(proposal => [proposal.proposal_id, proposal]), + ); + const requestRecord = req as unknown as Record; + const requestAccount = isRecord(requestRecord.account) ? requestRecord.account : undefined; + const requestBrand = isRecord(requestRecord.brand) ? requestRecord.brand : undefined; + const proposalOwner = JSON.stringify({ + ...(typeof requestAccount?.account_id === 'string' && { account_id: requestAccount.account_id }), + brand: { + domain: typeof requestBrand?.domain === 'string' ? requestBrand.domain.toLowerCase() : '', + ...(typeof requestBrand?.brand_id === 'string' && { brand_id: requestBrand.brand_id }), + }, + }); + proposals = proposals.map((proposal, index) => { + const digest = createHash('sha256') + .update(`${proposalOwner}:${key}:${proposal.proposal_id}:${index}`) + .digest('hex') + .slice(0, 24); + const proposalId = `proposal_request_${digest}`; + return existingById.get(proposalId) ?? { + ...proposal, + proposal_id: proposalId, + proposal_status: 'draft' as const, + expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + ...(typeof requestBrand?.domain === 'string' && { __brand_domain: requestBrand.domain.toLowerCase() }), + ...(typeof requestBrand?.brand_id === 'string' && { __brand_id: requestBrand.brand_id }), + ...(typeof requestAccount?.account_id === 'string' && { __account_id: requestAccount.account_id }), + }; + }); + if (proposals.length === 0) { + return { + status: 'rejected', + reason: 'The seller could not construct a proposal satisfying the supplied product and campaign criteria.', + suggestions: ['Broaden the product selection or campaign constraints and retry with a new idempotency key.'], + } as GetProductsRejectedResponse; + } + } + const sourceProposalOrder = immutableRefine && Array.isArray(req.refine) + ? (req.refine as unknown as RefineEntry[]) + .filter((entry): entry is Extract => entry.scope === 'proposal') + .map(entry => entry.proposal_id) + : []; + if (immutableRefine) { + const key = typeof (req as unknown as Record).idempotency_key === 'string' + ? (req as unknown as Record).idempotency_key as string + : 'unkeyed'; + const proposalsById = new Map(proposals.map(proposal => [proposal.proposal_id, proposal])); + const outcomesBySource = new Map( + refinementApplied + .filter((entry): entry is Extract => entry.scope === 'proposal') + .map(entry => [entry.proposal_id, entry]), + ); + proposals = sourceProposalOrder.flatMap((sourceId, index) => { + const proposal = proposalsById.get(sourceId); + const outcome = outcomesBySource.get(sourceId); + if (!proposal || outcome?.status === 'unable') return []; + const digest = createHash('sha256').update(`${key}:${sourceId}:${index}`).digest('hex').slice(0, 24); + return [{ + ...proposal, + proposal_id: `proposal_revision_${digest}`, + proposal_status: 'draft' as const, + __source_proposal_id: sourceId, + __refinement_outcome: outcome?.status === 'partial' ? 'partial' : 'revised', + ...(outcome?.notes && { __refinement_notes: outcome.notes }), + }]; + }); + } const canonicalFormatAdvisories = collectCanonicalFormatAdvisories(products); const staleDirective = readDirectives ? readDirectives.staleDirective @@ -5478,7 +5925,17 @@ async function handleGetProductsUnlocked( .filter(proposal => proposalLifecycle(proposal).proposal_status === 'committed') .map(proposal => [proposal.proposal_id, proposal]), ); - const persistedProposals = buyingMode === 'wholesale' ? [] : [...proposals]; + const priorProposals = session.lastGetProductsContext?.proposals ?? []; + const persistedProposals = buyingMode === 'wholesale' + ? [] + : immutableRefine + ? [...priorProposals, ...proposals] + : requireProposals + ? [ + ...priorProposals.filter(prior => !proposals.some(proposal => proposal.proposal_id === prior.proposal_id)), + ...proposals, + ] + : [...proposals]; const persistedProposalIds = new Set(persistedProposals.map(proposal => proposal.proposal_id)); for (const proposal of retainedCommittedProposals.values()) { if (!persistedProposalIds.has(proposal.proposal_id)) persistedProposals.push(proposal); @@ -5486,7 +5943,7 @@ async function handleGetProductsUnlocked( // Only refine requests establish durable context for later refinements. // Brief/wholesale discovery must remain read-only so concurrent reads cannot // overwrite a proposal committed by a serialized refine request. - if (buyingMode === 'refine') { + if (buyingMode === 'refine' || requireProposals) { session.lastGetProductsContext = { products: responseProducts, proposals: persistedProposals, @@ -6821,6 +7278,40 @@ export async function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) // Check session proposals first (may have finalized versions), then global catalog let proposal = session.lastGetProductsContext?.proposals?.find(p => p.proposal_id === req.proposal_id) || getProposals().find(p => p.proposal_id === req.proposal_id); + if (!proposal) { + // Compact proposal lifecycle calls intentionally address opaque IDs + // under the authenticated principal instead of repeating account data. + // Resolve that principal-owned proposal here, then verify its internal + // account/brand binding against the billed account without exposing + // whether a cross-account proposal exists. + const proposalSession = await getSession( + sessionKeyFromArgs({}, ctx.mode, ctx.userId, ctx.moduleId, ctx.principal ?? 'anonymous'), + ); + const candidate = proposalSession.lastGetProductsContext?.proposals?.find( + p => p.proposal_id === req.proposal_id, + ); + if (candidate) proposal = candidate; + } + if (proposal) { + const internal = proposal as unknown as Record; + const accountRef = req.account as unknown as { account_id?: string }; + const requestBrand = req.brand as unknown as { domain?: string; brand_id?: string }; + const boundAccountId = internal.__account_id; + const boundBrandDomain = internal.__brand_domain; + const boundBrandId = internal.__brand_id; + const hasCompactOwnerBinding = typeof boundAccountId === 'string' + || typeof boundBrandDomain === 'string' + || typeof boundBrandId === 'string'; + const accountMatches = typeof boundAccountId !== 'string' + || accountRef?.account_id === boundAccountId; + const brandMatches = typeof boundBrandDomain !== 'string' + || ( + requestBrand?.domain?.toLowerCase() === boundBrandDomain + && (typeof requestBrand.brand_id === 'string' ? requestBrand.brand_id : undefined) + === (typeof boundBrandId === 'string' ? boundBrandId : undefined) + ); + if (hasCompactOwnerBinding && (!accountMatches || !brandMatches)) proposal = undefined; + } if (!proposal && isThreeZeroStoryboardCompat(ctx) && req.proposal_id === THREE_ZERO_LEGACY_PROPOSAL_ID) { proposal = resolveThreeZeroProposalAlias([...(session.lastGetProductsContext?.proposals ?? []), ...getProposals()]); } @@ -6839,14 +7330,14 @@ export async function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) const proposalStatus = proposalLifecycle(proposal).proposal_status; if (proposalStatus === 'draft' && !(isThreeZeroStoryboardCompat(ctx) && req.proposal_id === THREE_ZERO_LEGACY_PROPOSAL_ID)) { return { - errors: [{ code: 'PROPOSAL_NOT_COMMITTED', message: `Proposal "${req.proposal_id}" has draft status — finalize it first using get_products with buying_mode "refine" and action "finalize".` }] as TaskError[], + errors: [{ code: 'PROPOSAL_NOT_COMMITTED', message: `Proposal "${req.proposal_id}" has draft status — finalize it first using finalize_proposals.` }] as TaskError[], }; } // Enforce proposal expiry if (proposal.expires_at && new Date(proposal.expires_at) < new Date()) { return { - errors: [{ code: 'PROPOSAL_EXPIRED', message: `Proposal "${req.proposal_id}" expired at ${proposal.expires_at}. Re-discover with get_products to get a fresh proposal.` }] as TaskError[], + errors: [{ code: 'PROPOSAL_EXPIRED', message: `Proposal "${req.proposal_id}" expired at ${proposal.expires_at}. Request and finalize a fresh proposal before retrying.` }] as TaskError[], }; } @@ -6964,7 +7455,7 @@ export async function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) // Enforce product expiry if (product.expires_at && new Date(product.expires_at) < new Date()) { - errors.push({ code: 'PRODUCT_EXPIRED', message: `${pkgLabel}: Product "${pkg.product_id}" expired at ${product.expires_at}. Re-discover with get_products.` }); + errors.push({ code: 'PRODUCT_EXPIRED', message: `${pkgLabel}: Product "${pkg.product_id}" expired at ${product.expires_at}. Re-discover with list_products.` }); continue; } @@ -8874,6 +9365,7 @@ export async function handleGetAdcpCapabilities(args: ToolArgs, ctx: TrainingCon buying_modes: wholesaleProfile.productWholesale ? ['brief', 'wholesale', 'refine'] : ['brief', 'refine'], ...(supportsGetProductsRejected(servedAdcpVersion) && { product_discovery_tools: [...PRODUCT_DISCOVERY_TOOLS], + max_atomic_finalize_batch_size: 25, }), supports_proposals: true, features: { @@ -10741,8 +11233,8 @@ type ToolHandler = (args: ToolArgs, ctx: TrainingContext) => object | Promise = { get_products: handleGetProducts, list_products: handleGetProducts, - recommend_products: handleGetProducts, - refine_proposal: handleGetProducts, + request_proposals: handleGetProducts, + refine_proposals: handleGetProducts, finalize_proposals: handleGetProducts, list_creative_formats: handleListCreativeFormats, validate_input: handleValidateInput, @@ -10923,6 +11415,13 @@ async function executeTrainingAgentToolInContext( if (isMutatingTool(toolName) && initialHandlerArgs.idempotency_key == null) { return { success: false, error: `idempotency_key is required for ${toolName}` }; } + const sourceSchemaName = productDiscoverySourceSchemaName(toolName); + const sourceValidationError = sourceSchemaName + ? validateProductDiscoverySourceInput(sourceSchemaName, rawArgs) + : undefined; + if (sourceValidationError) { + return { success: false, error: sourceValidationError.message }; + } const aliasValidationError = validateProductDiscoveryAliasInput(toolName, initialHandlerArgs); if (aliasValidationError) { return { success: false, error: aliasValidationError.message }; @@ -10933,7 +11432,7 @@ async function executeTrainingAgentToolInContext( try { accountScope = toolName === 'comply_test_controller' ? undefined - : deriveAccountScope(normalizedHandlerArgs, isProductDiscoveryTool(toolName)); + : await deriveProductDiscoveryAccountScope(toolName, initialHandlerArgs, normalizedHandlerArgs, ctx); } catch (error) { if (!(error instanceof AccountRefValidationError)) throw error; return { success: false, error: `Invalid ${toolName} request at account: ${error.message}` }; @@ -10969,13 +11468,22 @@ async function executeTrainingAgentToolInContext( const outcome = await getIdempotencyStore().check({ principal, key: idempotencyKey, - payload: idempotencyPayloadForServedVersion(toolName, handlerArgs, versionResolution.servedVersion), + payload: idempotencyPayloadForServedVersion( + toolName, + sourceSchemaName ? initialHandlerArgs : handlerArgs, + versionResolution.servedVersion, + ), }); if (outcome.kind === 'replay') { + const replayed = projectProductDiscoveryResult( + toolName, + outcome.response as Record, + initialHandlerArgs, + ); return { success: true, data: { - ...(outcome.response as object), + ...replayed, adcp_version: versionResolution.servedVersion, replayed: true, ...(callerContext !== undefined && { context: callerContext }), @@ -11002,7 +11510,12 @@ async function executeTrainingAgentToolInContext( { ...ctx, servedAdcpVersion: versionResolution.servedVersion }, )); const cacheResponse = addServedAdcpVersion(result, versionResolution.servedVersion) as Record; - const response = addServedAdcpVersion(cacheResponse, versionResolution.servedVersion, callerContext) as Record; + const projectedResponse = projectProductDiscoveryResult( + toolName, + result as Record, + initialHandlerArgs, + ); + const response = addServedAdcpVersion(projectedResponse, versionResolution.servedVersion, callerContext) as Record; if (claim && typeof idempotencyKey === 'string') { const hasErrors = Array.isArray(cacheResponse.errors) && cacheResponse.errors.length > 0; const hasAdvisorySuccessPayload = permitsAdvisoryErrors(toolName, cacheResponse); @@ -11129,6 +11642,21 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { }; } + const sourceSchemaName = productDiscoverySourceSchemaName(name); + const sourceValidationError = sourceSchemaName + ? validateProductDiscoverySourceInput(sourceSchemaName, rawArgs) + : undefined; + if (sourceValidationError) { + return { + result: adcpError('INVALID_REQUEST', { + message: sourceValidationError.message, + ...(sourceValidationError.field && { field: sourceValidationError.field }), + recovery: 'correctable', + }, callerContext, servedAdcpVersion), + flushable: true, + }; + } + const aliasValidationError = validateProductDiscoveryAliasInput(name, initialHandlerArgs); if (aliasValidationError) { return { @@ -11168,7 +11696,7 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { try { accountScope = name === 'comply_test_controller' ? undefined - : deriveAccountScope(normalizedHandlerArgs, isProductDiscoveryTool(name)); + : await deriveProductDiscoveryAccountScope(name, initialHandlerArgs, normalizedHandlerArgs, ctx); } catch (error) { if (!(error instanceof AccountRefValidationError)) throw error; return { @@ -11238,7 +11766,11 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { }; } const store = getIdempotencyStore(); - const idempotencyPayload = idempotencyPayloadForServedVersion(name, handlerArgs, servedAdcpVersion); + const idempotencyPayload = idempotencyPayloadForServedVersion( + name, + sourceSchemaName ? initialHandlerArgs : handlerArgs, + servedAdcpVersion, + ); const outcome = await store.check({ principal: idempotencyPrincipal, key: idempotencyKey, @@ -11286,7 +11818,12 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { // before the envelope-fold landed, or handlers that emitted bodies // without status). Per #4878, every per-task response schema now // requires envelope `status`. - const body: Record = { ...(outcome.response as Record), replayed: true }; + const replayBody = projectProductDiscoveryResult( + name, + outcome.response as Record, + initialHandlerArgs, + ); + const body: Record = { ...replayBody, replayed: true }; if (!isTaskRequest && body.status === undefined) body.status = 'completed'; body.adcp_version = servedAdcpVersion; if (callerContext !== undefined) body.context = callerContext; @@ -11314,7 +11851,7 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { if (isTaskRequest) { const naturalKey = idempotentTaskNaturalKey( idempotencyPrincipal, - canonicalProductDiscoveryTool(name), + name, idempotencyKey, idempotencyPayloadHash, ); @@ -11373,8 +11910,8 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { claimToken: idempotencyClaimToken, }); maybeEmitCompletionWebhook({ - toolName: canonicalProductDiscoveryTool(name), - args: handlerArgs, + toolName: name, + args: initialHandlerArgs, response: notificationResponse, requestIdempotencyKey: idempotencyKey, principal: idempotencyPrincipal, @@ -11465,7 +12002,8 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { // `completed` on synchronous success — handlers that emit a different // TaskStatus (e.g., `submitted` for async-task envelopes) set it on // `inner` and we honor that value. - const response: Record = { ...inner }; + const outwardInner = projectProductDiscoveryResult(name, inner, initialHandlerArgs); + const response: Record = { ...outwardInner }; if (response.status === undefined) response.status = 'completed'; response.adcp_version = servedAdcpVersion; if (name === 'create_media_buy') response.replayed = false; @@ -11550,8 +12088,8 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { || handlerThrew ) return; maybeEmitCompletionWebhook({ - toolName: canonicalProductDiscoveryTool(name), - args: handlerArgs, + toolName: name, + args: initialHandlerArgs, response: cachableResponse, requestIdempotencyKey: typeof idempotencyKey === 'string' ? idempotencyKey : undefined, principal: idempotencyPrincipal, @@ -11608,8 +12146,8 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { ...rawTaskRequest, params: { ...rawTaskRequest.params, - name: canonicalProductDiscoveryTool(name), - arguments: handlerArgs, + name, + arguments: initialHandlerArgs, }, } : rawTaskRequest; @@ -11618,7 +12156,7 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { taskStore, idempotentTaskNaturalKey( idempotencyPrincipal, - canonicalProductDiscoveryTool(name), + name, idempotencyKey!, idempotencyPayloadHash!, ), diff --git a/server/src/training-agent/tenants/router.ts b/server/src/training-agent/tenants/router.ts index 5ee99fda40..cf4c31112b 100644 --- a/server/src/training-agent/tenants/router.ts +++ b/server/src/training-agent/tenants/router.ts @@ -133,8 +133,8 @@ const TRAINING_AGENT_DEFAULT_ADCP_VERSION = '3.0'; const PRODUCT_DISCOVERY_TOOL_NAMES = [ 'get_products', 'list_products', - 'recommend_products', - 'refine_proposal', + 'request_proposals', + 'refine_proposals', 'finalize_proposals', ] as const; diff --git a/server/src/training-agent/tenants/tenant-smoke.test.ts b/server/src/training-agent/tenants/tenant-smoke.test.ts index eb1b6dadcc..2823335ba2 100644 --- a/server/src/training-agent/tenants/tenant-smoke.test.ts +++ b/server/src/training-agent/tenants/tenant-smoke.test.ts @@ -1541,27 +1541,26 @@ describe('tenant routing smoke', () => { expect(listBody.result?.tools?.map(tool => tool.name)).toEqual(expect.arrayContaining([ 'list_products', - 'recommend_products', - 'refine_proposal', + 'request_proposals', + 'refine_proposals', 'finalize_proposals', ])); const listAlias = listBody.result?.tools?.find(tool => tool.name === 'list_products'); - const recommendAlias = listBody.result?.tools?.find(tool => tool.name === 'recommend_products'); + const recommendAlias = listBody.result?.tools?.find(tool => tool.name === 'request_proposals'); expect(listAlias?.execution).toEqual({ taskSupport: 'forbidden' }); - expect(listAlias?.inputSchema).toMatchObject({ dependencies: { catalog: ['brand'] } }); + expect(listAlias?.inputSchema).toMatchObject({ dependencies: { if_pricing_version: ['if_feed_version'] } }); expect(recommendAlias?.execution).toEqual({ taskSupport: 'optional' }); expect(recommendAlias?.inputSchema).toMatchObject({ - dependencies: { catalog: ['brand'] }, properties: { brief: { type: 'string', minLength: 1 } }, }); - const refineAlias = listBody.result?.tools?.find(tool => tool.name === 'refine_proposal'); - expect(refineAlias?.inputSchema?.properties?.refine).toMatchObject({ - allOf: expect.arrayContaining([ - { $ref: '#/$defs/media-buy~1product-refinement.json' }, - ]), + const refineAlias = listBody.result?.tools?.find(tool => tool.name === 'refine_proposals'); + expect(refineAlias?.inputSchema?.properties?.refinements).toMatchObject({ + type: 'array', + minItems: 1, + items: { $ref: '#/$defs/media-buy~1proposal-refinement.json' }, }); - expect(refineAlias?.inputSchema?.$defs?.['media-buy/product-refinement.json']) - .toMatchObject({ type: 'array', minItems: 1 }); + expect(refineAlias?.inputSchema?.$defs?.['media-buy/proposal-refinement.json']) + .toMatchObject({ type: 'object', required: ['proposal_id'] }); const keylessLegacy = await callTenantTool(url, 3, 'get_products', { buying_mode: 'wholesale', @@ -1579,8 +1578,8 @@ describe('tenant routing smoke', () => { }); const invalidKeylessList = await callTenantTool(url, 32, 'list_products', { - account, - pagination: { max_results: 0 }, + brand: account.brand, + max_results: 0, }) as { result?: { structuredContent?: { adcp_error?: { code?: string; field?: string } } } }; expect(invalidKeylessList.result?.structuredContent?.adcp_error).toMatchObject({ code: 'INVALID_REQUEST', @@ -1589,14 +1588,14 @@ describe('tenant routing smoke', () => { const malformedAccount = await callTenantTool(url, 33, 'list_products', { account: 'not-an-account', - }) as { result?: { structuredContent?: { adcp_error?: { code?: string; field?: string } } } }; - expect(malformedAccount.result?.structuredContent?.adcp_error).toMatchObject({ - code: 'INVALID_REQUEST', - field: 'account', + }) as { error?: { code?: number; data?: { field?: string } } }; + expect(malformedAccount.error).toMatchObject({ + code: -32602, + data: { field: 'account' }, }); const unsupportedAliasVersion = await callTenantTool(url, 34, 'list_products', { - account, + brand: account.brand, adcp_version: '99.0', }) as { result?: { @@ -1623,14 +1622,13 @@ describe('tenant routing smoke', () => { expect(replay.result?.structuredContent?.replayed).toBe(true); const aliasReplay = await callTenantTool(url, 6, 'list_products', { - idempotency_key: payload.idempotency_key, adcp_version: payload.adcp_version, - account, + brand: account.brand, }) as { result?: { structuredContent?: { adcp_version?: string; products?: unknown[]; replayed?: boolean } } }; expect(aliasReplay.result?.structuredContent).not.toHaveProperty('adcp_error'); expect(aliasReplay.result?.structuredContent?.adcp_version).toBe('3.2-beta.0'); expect(aliasReplay.result?.structuredContent?.products).toEqual(first.result?.structuredContent?.products); - expect(aliasReplay.result?.structuredContent?.replayed).toBe(true); + expect(aliasReplay.result?.structuredContent?.replayed).toBeUndefined(); const taskKey = 'tenant-products-task-receipt-0001'; const taskCall = async (id: number): Promise> => { @@ -1646,10 +1644,10 @@ describe('tenant routing smoke', () => { id, method: 'tools/call', params: { - name: 'recommend_products', + name: 'request_proposals', arguments: { idempotency_key: taskKey, - account, + brand: account.brand, brief: 'Reach sports fans', }, task: { ttl: 120000 }, @@ -1712,7 +1710,7 @@ describe('tenant routing smoke', () => { method: 'tools/call', params: { name: 'list_products', - arguments: { account }, + arguments: { brand: account.brand }, task: { ttl: 120000 }, }, }), @@ -1720,8 +1718,8 @@ describe('tenant routing smoke', () => { const forbiddenListTaskBody = await forbiddenListTask.json() as { error?: { code?: number; message?: string } }; expect(forbiddenListTaskBody.error?.message).toContain('does not support task augmentation'); - const missingAliasKey = await callTenantTool(url, 61, 'recommend_products', { - account, + const missingAliasKey = await callTenantTool(url, 61, 'request_proposals', { + brand: account.brand, brief: 'Reach sports fans', }) as { error?: { code?: number; data?: { field?: string } } }; expect(missingAliasKey.error).toMatchObject({ diff --git a/server/src/training-agent/tenants/tool-catalog.ts b/server/src/training-agent/tenants/tool-catalog.ts index 375cb9d083..42ac074c72 100644 --- a/server/src/training-agent/tenants/tool-catalog.ts +++ b/server/src/training-agent/tenants/tool-catalog.ts @@ -35,8 +35,8 @@ export const TOOL_CATALOG: Readonly> = { sync_catalogs: ['sales', 'si'], get_products: ['sales'], list_products: ['sales'], - recommend_products: ['sales'], - refine_proposal: ['sales'], + request_proposals: ['sales'], + refine_proposals: ['sales'], finalize_proposals: ['sales'], create_media_buy: ['sales'], update_media_buy: ['sales'], @@ -137,8 +137,8 @@ export function toolsForTenant( // /signals keeps it across versions. if ( tool === 'list_products' - || tool === 'recommend_products' - || tool === 'refine_proposal' + || tool === 'request_proposals' + || tool === 'refine_proposals' || tool === 'finalize_proposals' ) return false; if (tool === 'validate_input' || tool === 'list_transformers') return false; diff --git a/server/tests/integration/training-agent-tool-catalog-drift.test.ts b/server/tests/integration/training-agent-tool-catalog-drift.test.ts index 2d254f3702..ed090eff0a 100644 --- a/server/tests/integration/training-agent-tool-catalog-drift.test.ts +++ b/server/tests/integration/training-agent-tool-catalog-drift.test.ts @@ -147,8 +147,8 @@ describe('tool-catalog drift detection', () => { }); const splitTools = [ 'list_products', - 'recommend_products', - 'refine_proposal', + 'request_proposals', + 'refine_proposals', 'finalize_proposals', ]; for (const tool of splitTools) { diff --git a/server/tests/integration/training-agent-webhooks.test.ts b/server/tests/integration/training-agent-webhooks.test.ts index d920bad815..6143b53a2f 100644 --- a/server/tests/integration/training-agent-webhooks.test.ts +++ b/server/tests/integration/training-agent-webhooks.test.ts @@ -180,31 +180,19 @@ describe('Training Agent webhook emission', () => { } }, 20000); - it('emits one canonical get_products webhook for a replayed 3.2 split call', async () => { + it('uses MCP task replay without a tool-specific webhook for request_proposals', async () => { const deliveries: CapturedDelivery[] = []; let srv: http.Server | undefined; try { - let resolveFirstDelivery: (() => void) | undefined; - const firstDelivery = new Promise(resolve => { resolveFirstDelivery = resolve; }); srv = await startReceiver((delivery, res) => { deliveries.push(delivery); res.writeHead(200); res.end(); - resolveFirstDelivery?.(); }); - const addr = srv.address() as AddressInfo; - const webhookUrl = `http://127.0.0.1:${addr.port}/hook/get_products`; const logicalRequest = { idempotency_key: `split-products-${randomUUID()}`, - account: { - brand: { domain: 'split-webhook.example' }, - operator: 'split-webhook.example', - }, + brand: { domain: 'split-webhook.example' }, brief: 'Reach sports fans', - push_notification_config: { - url: webhookUrl, - operation_id: 'op_split_products', - }, }; const call = () => request(app) .post('/api/training-agent/sales/mcp') @@ -215,27 +203,16 @@ describe('Training Agent webhook emission', () => { jsonrpc: '2.0', id: randomUUID(), method: 'tools/call', - params: { name: 'recommend_products', arguments: logicalRequest }, + params: { name: 'request_proposals', arguments: logicalRequest }, }); const first = await call(); const replay = await call(); expect(structuredToolResult(first)).not.toHaveProperty('adcp_error'); expect(structuredToolResult(replay)).toMatchObject({ replayed: true }); - await Promise.race([ - firstDelivery, - new Promise((_, reject) => setTimeout(() => reject(new Error('webhook never arrived')), 10_000)), - ]); await new Promise(resolve => setTimeout(resolve, 100)); - expect(deliveries).toHaveLength(1); - const body = JSON.parse(deliveries[0].body) as Record; - expect(body).toMatchObject({ - operation_id: 'op_split_products', - task_type: 'get_products', - protocol: 'media-buy', - status: 'completed', - }); + expect(deliveries).toHaveLength(0); } finally { if (srv) { srv.closeAllConnections?.(); diff --git a/server/tests/unit/idempotency.test.ts b/server/tests/unit/idempotency.test.ts index 8f4feb85d9..25bef78429 100644 --- a/server/tests/unit/idempotency.test.ts +++ b/server/tests/unit/idempotency.test.ts @@ -72,7 +72,7 @@ describe('idempotency facade', () => { it('covers specific mutating tools explicitly', () => { for (const name of [ 'create_media_buy', 'update_media_buy', 'sync_audiences', - 'recommend_products', 'refine_proposal', 'finalize_proposals', + 'request_proposals', 'refine_proposals', 'finalize_proposals', 'si_initiate_session', 'si_send_message', 'acquire_rights', 'update_rights', 'creative_approval', ]) { diff --git a/server/tests/unit/product-discovery-schema-parity.test.ts b/server/tests/unit/product-discovery-schema-parity.test.ts index 1f1f4d509d..94b7e2c611 100644 --- a/server/tests/unit/product-discovery-schema-parity.test.ts +++ b/server/tests/unit/product-discovery-schema-parity.test.ts @@ -13,25 +13,6 @@ function sourceSchema(name: string): JsonSchema { )) as JsonSchema; } -function forbiddenFields(schema: JsonSchema): string[] { - return (schema.allOf ?? []).flatMap((entry: JsonSchema) => ( - entry.not?.anyOf ?? [] - ).flatMap((clause: JsonSchema) => clause.required ?? [])).sort(); -} - -function refinementBranches(schema: JsonSchema): unknown { - return schema.items.oneOf.map((branch: JsonSchema) => ({ - scope: branch.properties.scope.const, - required: branch.required, - additionalProperties: branch.additionalProperties, - action: branch.properties.action?.enum, - fieldMinimums: Object.fromEntries(Object.entries(branch.properties) - .flatMap(([name, value]) => (value as JsonSchema).minLength === undefined - ? [] - : [[name, (value as JsonSchema).minLength]])), - })); -} - function resolveLocalRef(root: JsonSchema, value: JsonSchema): JsonSchema { const ref = value.$ref as string | undefined; if (!ref?.startsWith('#/')) return value; @@ -45,39 +26,45 @@ describe('product discovery MCP schema parity', () => { const tools = new Map(productDiscoveryAliasToolDefinitions().map(tool => [tool.name, tool.inputSchema as JsonSchema])); for (const [toolName, fileName] of [ ['list_products', 'list-products-request'], - ['recommend_products', 'recommend-products-request'], - ['refine_proposal', 'refine-proposal-request'], + ['request_proposals', 'request-proposals-request'], + ['refine_proposals', 'refine-proposals-request'], ['finalize_proposals', 'finalize-proposals-request'], ] as const) { const runtime = tools.get(toolName)!; const source = sourceSchema(fileName); expect(runtime.required ?? []).toEqual(source.required ?? []); expect(runtime.dependencies ?? {}).toEqual(source.dependencies ?? {}); - expect(forbiddenFields(runtime)).toEqual(forbiddenFields(source)); - expect(runtime.properties.idempotency_key).toMatchObject({ - minLength: source.properties.idempotency_key.minLength, - maxLength: source.properties.idempotency_key.maxLength, - pattern: source.properties.idempotency_key.pattern, - }); + expect(runtime.additionalProperties).toBe(false); + expect(Object.keys(runtime.properties).sort()).toEqual(Object.keys(source.properties).sort()); + if (source.properties.idempotency_key) { + expect(runtime.properties.idempotency_key).toMatchObject({ + minLength: source.properties.idempotency_key.minLength, + maxLength: source.properties.idempotency_key.maxLength, + pattern: source.properties.idempotency_key.pattern, + }); + } } - const recommend = tools.get('recommend_products')!; + const recommend = tools.get('request_proposals')!; expect(recommend.properties.brief.minLength) - .toBe(sourceSchema('recommend-products-request').properties.brief.minLength); + .toBe(sourceSchema('request-proposals-request').properties.brief.minLength); const list = tools.get('list_products')!; - expect(list.allOf).toEqual(expect.arrayContaining([ - expect.objectContaining({ - if: { required: ['if_pricing_version'] }, - then: { required: ['if_wholesale_feed_version'] }, - }), - ])); + expect(list.dependencies).toEqual({ if_pricing_version: ['if_feed_version'] }); + const criteria = resolveLocalRef(list, list.properties.criteria); + const offerFilters = resolveLocalRef(list, criteria.properties.offer_filters); + expect(offerFilters.properties.pricing_structures).toBeDefined(); + expect(offerFilters.properties.required_performance_standards.type).toBe('array'); + expect(offerFilters.properties.required_vendor_metrics.type).toBe('array'); - const refineTool = tools.get('refine_proposal')!; - const runtimeRefinement = resolveLocalRef(refineTool, refineTool.properties.refine.allOf[0]); - const sourceRefinement = sourceSchema('product-refinement'); - expect(runtimeRefinement).toMatchObject({ type: sourceRefinement.type, minItems: sourceRefinement.minItems }); - expect(refinementBranches(runtimeRefinement)).toEqual(refinementBranches(sourceRefinement)); + const refineTool = tools.get('refine_proposals')!; + const runtimeRefinement = resolveLocalRef(refineTool, refineTool.properties.refinements.items); + const sourceRefinement = sourceSchema('proposal-refinement'); + expect(runtimeRefinement).toMatchObject({ + type: sourceRefinement.type, + required: sourceRefinement.required, + additionalProperties: false, + }); // Repository-local refs must be bundled because MCP consumers do not have // an AdCP schema registry attached to tools/list. @@ -85,17 +72,14 @@ describe('product discovery MCP schema parity', () => { expect(JSON.stringify(runtime)).not.toContain('"$ref":"/schemas/'); } expect(resolveLocalRef(list, list.properties.brand)).toMatchObject({ required: ['domain'], additionalProperties: false }); - expect(resolveLocalRef(list, list.properties.catalog)).toMatchObject({ required: ['type'] }); expect(resolveLocalRef(list, list.properties.fields)).toMatchObject({ minItems: 1, uniqueItems: true, items: { enum: expect.arrayContaining(['product_id', 'format_options', 'pricing_options']) }, }); - expect(resolveLocalRef(list, list.properties.property_list)).toMatchObject({ - required: ['agent_url', 'list_id'], - additionalProperties: false, - }); - expect(resolveLocalRef(list, list.properties.pagination)).toMatchObject({ additionalProperties: false }); + expect(resolveLocalRef(list, list.properties.criteria)).toMatchObject({ additionalProperties: false }); + expect(resolveLocalRef(refineTool, refineTool.properties.refinements.items)) + .toMatchObject({ required: ['proposal_id', 'instructions'], additionalProperties: false }); }); it('bundles each tools/list input schema as a valid standalone document', () => { @@ -104,4 +88,19 @@ describe('product discovery MCP schema parity', () => { expect(() => ajv.compile(tool.inputSchema), tool.name).not.toThrow(); } }); + + it('keeps the compact lifecycle within its tools/list context budget', () => { + const tools = productDiscoveryAliasToolDefinitions(); + const totalBytes = tools.reduce( + (sum, tool) => sum + Buffer.byteLength(JSON.stringify(tool.inputSchema)), + 0, + ); + expect(totalBytes).toBeLessThanOrEqual(40 * 1024); + + const list = tools.find(tool => tool.name === 'list_products')!.inputSchema as JsonSchema; + const criteria = resolveLocalRef(list, list.properties.criteria); + expect(criteria.properties.offer_filters).toBeDefined(); + expect(criteria.properties).not.toHaveProperty('targeting_overlay'); + expect(criteria.properties).not.toHaveProperty('required_overlay_support'); + }); }); diff --git a/server/tests/unit/training-agent-idempotency.test.ts b/server/tests/unit/training-agent-idempotency.test.ts index e6b28c0e6e..b6ab141859 100644 --- a/server/tests/unit/training-agent-idempotency.test.ts +++ b/server/tests/unit/training-agent-idempotency.test.ts @@ -164,19 +164,19 @@ describe('training agent idempotency middleware', () => { }); const list = await call(server, 'list_products', { - account: ACCOUNT, - pagination: { max_results: 0 }, + account_id: 'idem-account', + max_results: 0, }); expect(list.isError).toBe(true); expect((list.parsed as any).adcp_error).toMatchObject({ code: 'INVALID_REQUEST', - field: 'pagination.max_results', + field: 'max_results', }); }); - it('accepts and ignores callback configuration on synchronous list_products', async () => { + it('accepts and ignores callback envelope configuration on synchronous list_products', async () => { const result = await call(server, 'list_products', { - account: ACCOUNT, + account_id: 'idem-account', push_notification_config: { url: 'https://callbacks.example/list-products', operation_id: 'list-products-wrapper-envelope', @@ -186,6 +186,40 @@ describe('training agent idempotency middleware', () => { expect(result.parsed.products).toEqual(expect.any(Array)); }); + it('validates compact linked and envelope fields against the source schema', async () => { + const malformedFilter = await call(server, 'list_products', { + criteria: { + offer_filters: { + required_vendor_metrics: { vendor: { domain: 'measure.example' } }, + }, + }, + }); + expect(malformedFilter.isError).toBe(true); + expect((malformedFilter.parsed as any).adcp_error).toMatchObject({ + code: 'INVALID_REQUEST', + field: 'criteria.offer_filters.required_vendor_metrics', + }); + + const malformedEnvelope = await call(server, 'request_proposals', { + idempotency_key: `proposal-${randomUUID()}`, + brand: BRAND, + brief: 'news video', + governance_context: 42, + }); + expect(malformedEnvelope.isError).toBe(true); + expect((malformedEnvelope.parsed as any).adcp_error).toMatchObject({ + code: 'INVALID_REQUEST', + field: 'governance_context', + }); + + const malformedContext = await call(server, 'list_products', { context: 'not-an-object' }); + expect(malformedContext.isError).toBe(true); + expect((malformedContext.parsed as any).adcp_error).toMatchObject({ + code: 'INVALID_REQUEST', + field: 'context', + }); + }); + it('adapts only served-3.0 missing-key get_products requests to safe deterministic replay', async () => { const legacyPayload = { adcp_version: '3.0', @@ -250,6 +284,54 @@ describe('training agent idempotency middleware', () => { }); describe('replay with same key + same payload', () => { + it('hashes context_id and callback routing while excluding rotated callback credentials', async () => { + const key = `proposal-callback-${randomUUID()}`; + const base = { + idempotency_key: key, + brand: BRAND, + brief: 'cross-channel sports', + context_id: 'campaign-context-a', + push_notification_config: { + url: 'https://callbacks.example/proposals/a', + operation_id: 'proposal-callback-test', + authentication: { + schemes: ['Bearer'], + credentials: 'a'.repeat(32), + }, + }, + }; + const first = await executeTrainingAgentTool('request_proposals', base as any, CTX); + expect(first.success).toBe(true); + + const rotatedCredential = await executeTrainingAgentTool('request_proposals', { + ...base, + push_notification_config: { + ...base.push_notification_config, + authentication: { + schemes: ['Bearer'], + credentials: 'b'.repeat(32), + }, + }, + } as any, CTX); + expect(rotatedCredential.success).toBe(true); + expect((rotatedCredential.data as any).replayed).toBe(true); + + const changedCallback = await executeTrainingAgentTool('request_proposals', { + ...base, + push_notification_config: { + ...base.push_notification_config, + url: 'https://callbacks.example/proposals/b', + }, + } as any, CTX); + expect(changedCallback).toMatchObject({ success: false, error: 'IDEMPOTENCY_CONFLICT' }); + + const changedContextId = await executeTrainingAgentTool('request_proposals', { + ...base, + context_id: 'campaign-context-b', + } as any, CTX); + expect(changedContextId).toMatchObject({ success: false, error: 'IDEMPOTENCY_CONFLICT' }); + }); + it('returns the cached media_buy_id with replayed: true', async () => { const { productId, pricingOptionId } = await getValidProductAndPricing(server); const key = `idem-${randomUUID()}`; @@ -375,32 +457,27 @@ describe('training agent idempotency middleware', () => { expect((conflict.parsed as any).adcp_error?.code).toBe('IDEMPOTENCY_CONFLICT'); }); - it('shares replay identity between legacy wholesale discovery and list_products', async () => { + it('keeps keyless list_products independent from legacy discovery replay identity', async () => { const key = `products-list-alias-${randomUUID()}`; - const shared = { - idempotency_key: key, + const identity = { adcp_version: '3.2-beta.0', - account: ACCOUNT, + brand: BRAND, brand: BRAND, }; const first = await call(server, 'get_products', { - ...shared, + idempotency_key: key, + adcp_version: identity.adcp_version, + account: ACCOUNT, + brand: BRAND, buying_mode: 'wholesale', }); expect(first.isError).toBeFalsy(); - const replay = await call(server, 'list_products', shared); - expect(replay.isError).toBeFalsy(); - expect(replay.parsed.replayed).toBe(true); - expect(replay.parsed.products).toEqual(first.parsed.products); - - const conflict = await call(server, 'list_products', { - ...shared, - fields: ['product_id'], - }); - expect(conflict.isError).toBe(true); - expect((conflict.parsed as any).adcp_error?.code).toBe('IDEMPOTENCY_CONFLICT'); + const listed = await call(server, 'list_products', identity); + expect(listed.isError).toBeFalsy(); + expect(listed.parsed.replayed).toBeUndefined(); + expect(listed.parsed.products).toEqual(first.parsed.products); }); it('treats caller-supplied version pins as part of product request identity', async () => { @@ -423,23 +500,21 @@ describe('training agent idempotency middleware', () => { expect((conflict.parsed as any).adcp_error?.code).toBe('IDEMPOTENCY_CONFLICT'); }); - it('does not replay unpinned product aliases across different effective releases', async () => { - const shared = { - idempotency_key: `products-effective-version-${randomUUID()}`, - account: ACCOUNT, - }; + it('keeps unpinned keyless list discovery independent from legacy replay state', async () => { + const key = `products-effective-version-${randomUUID()}`; const legacy = await call(server, 'get_products', { - ...shared, + idempotency_key: key, + account: ACCOUNT, buying_mode: 'wholesale', }); expect(legacy.isError).toBeFalsy(); - const split = await call(server, 'list_products', shared); - expect(split.isError).toBe(true); - expect((split.parsed as any).adcp_error?.code).toBe('IDEMPOTENCY_CONFLICT'); + const split = await call(server, 'list_products', { brand: BRAND }); + expect(split.isError).toBeFalsy(); + expect(split.parsed.replayed).toBeUndefined(); }); - it('reuses one task receipt when a brief retry switches to recommend_products', async () => { + it('does not reuse a legacy task receipt for request_proposals', async () => { const key = `products-recommend-task-alias-${randomUUID()}`; const shared = { idempotency_key: key, @@ -451,27 +526,33 @@ describe('training agent idempotency middleware', () => { const first = await callAsTask(server, 'get_products', { ...shared, + account: undefined, buying_mode: 'brief', }); const firstTaskId = (first.parsed.task as { taskId?: string })?.taskId; expect(firstTaskId).toBeTruthy(); - const replay = await callAsTask(server, 'recommend_products', shared); - expect((replay.parsed.task as { taskId?: string })?.taskId).toBe(firstTaskId); - expect(replay.parsed.replayed).toBe(true); + const split = await call(server, 'request_proposals', { + idempotency_key: key, + adcp_version: '3.2-beta.0', + brand: BRAND, + brief: shared.brief, + }); + expect(split.isError).toBe(true); + expect((split.parsed as any).adcp_error?.code).toBe('IDEMPOTENCY_CONFLICT'); }); it('projects one cached product result across inline then task execution modes', async () => { const shared = { idempotency_key: `products-inline-task-${randomUUID()}`, adcp_version: '3.2-beta.0', - account: ACCOUNT, + brand: BRAND, brief: 'cross-channel sports', }; - const inline = await call(server, 'recommend_products', shared); - expect(inline.isError).toBeFalsy(); + const inline = await call(server, 'request_proposals', shared); + expect(inline.isError, JSON.stringify(inline.parsed)).toBeFalsy(); - const task = await callAsTask(server, 'recommend_products', shared); + const task = await callAsTask(server, 'request_proposals', shared); expect(task.isError).toBeFalsy(); expect(task.parsed).toMatchObject({ replayed: true, @@ -485,18 +566,40 @@ describe('training agent idempotency middleware', () => { const shared = { idempotency_key: `products-task-inline-${randomUUID()}`, adcp_version: '3.2-beta.0', - account: ACCOUNT, + brand: BRAND, brief: 'cross-channel news', }; - const task = await callAsTask(server, 'recommend_products', shared); + const task = await callAsTask(server, 'request_proposals', shared); expect(task.parsed.task).toMatchObject({ taskId: expect.any(String), status: 'completed' }); - const inline = await call(server, 'recommend_products', shared); + const inline = await call(server, 'request_proposals', shared); expect(inline.isError).toBeFalsy(); expect(inline.parsed).toMatchObject({ replayed: true, products: expect.any(Array) }); expect(inline.parsed).not.toHaveProperty('task'); }); + it('binds refine_proposals replay identity to ordered instructions', async () => { + const requested = await call(server, 'request_proposals', { + idempotency_key: `proposal-request-${randomUUID()}`, + brand: BRAND, + brief: 'cross-channel sports', + }); + const proposalId = (requested.parsed.proposals as Array<{ proposal_id: string }>)[0].proposal_id; + const key = `proposal-refine-${randomUUID()}`; + const first = await call(server, 'refine_proposals', { + idempotency_key: key, + refinements: [{ proposal_id: proposalId, instructions: 'Prefer video.' }], + }); + expect(first.isError).toBeFalsy(); + + const conflict = await call(server, 'refine_proposals', { + idempotency_key: key, + refinements: [{ proposal_id: proposalId, instructions: 'Prefer audio.' }], + }); + expect(conflict.isError).toBe(true); + expect((conflict.parsed as any).adcp_error?.code).toBe('IDEMPOTENCY_CONFLICT'); + }); + it('validates the complete get_products payload before consulting the cache', async () => { const key = `products-schema-first-${randomUUID()}`; await call(server, 'get_products', { @@ -627,13 +730,13 @@ describe('training agent idempotency middleware', () => { const payload = { idempotency_key: key, brief: 'A retry-safe campaign recommendation', - account: ACCOUNT, + brand: BRAND, }; const taskStore = getTrainingTaskStore(); const storeFailure = vi.spyOn(taskStore, 'storeTaskResult') .mockRejectedValueOnce(new Error('injected task-result persistence failure')); - await expect(callAsTask(server, 'recommend_products', payload)) + await expect(callAsTask(server, 'request_proposals', payload)) .rejects.toThrow('injected task-result persistence failure'); storeFailure.mockRestore(); @@ -641,7 +744,7 @@ describe('training agent idempotency middleware', () => { expect(orphan?.taskId).toBeTruthy(); await taskStore.updateTaskStatus(orphan!.taskId, 'cancelled', 'cancelled by buyer'); - const cancelledRetry = await callAsTask(server, 'recommend_products', payload); + const cancelledRetry = await callAsTask(server, 'request_proposals', payload); expect(cancelledRetry.parsed).toMatchObject({ replayed: true, task: { taskId: orphan!.taskId, status: 'cancelled' }, @@ -649,11 +752,11 @@ describe('training agent idempotency middleware', () => { // The cancelled receipt released the cache claim instead of publishing // a hidden success. A later inline request therefore executes normally. - const inlineRetry = await call(server, 'recommend_products', payload); + const inlineRetry = await call(server, 'request_proposals', payload); expect(inlineRetry.isError).not.toBe(true); expect(inlineRetry.parsed.replayed).toBeUndefined(); - const taskReplay = await callAsTask(server, 'recommend_products', payload); + const taskReplay = await callAsTask(server, 'request_proposals', payload); const replacementTask = taskReplay.parsed.task as { taskId: string; status: string }; expect(replacementTask).toMatchObject({ status: 'completed' }); expect(replacementTask.taskId).not.toBe(orphan!.taskId); @@ -662,7 +765,7 @@ describe('training agent idempotency middleware', () => { expect((replacementResult.structuredContent as { proposals?: unknown[] })?.proposals?.length) .toBeGreaterThan(0); - const stableReplay = await callAsTask(server, 'recommend_products', payload); + const stableReplay = await callAsTask(server, 'request_proposals', payload); expect(stableReplay.parsed).toMatchObject({ replayed: true, task: { taskId: replacementTask.taskId, status: 'completed' }, diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index 0a5b88ebba..8e0459b67d 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -1369,8 +1369,8 @@ describe('createTrainingAgentServer', () => { expect(toolNames).toContain('get_products'); expect(toolNames).toContain('list_products'); - expect(toolNames).toContain('recommend_products'); - expect(toolNames).toContain('refine_proposal'); + expect(toolNames).toContain('request_proposals'); + expect(toolNames).toContain('refine_proposals'); expect(toolNames).toContain('finalize_proposals'); expect(toolNames).toContain('list_creative_formats'); expect(toolNames).toContain('create_media_buy'); @@ -12555,6 +12555,158 @@ describe('proposal lifecycle', () => { const account = { brand: { domain: 'proposal-test.example' }, operator: 'proposal-test.example' }; + it('connects the compact request, refine, finalize, and purchase lifecycle', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const { result: requested, isError: requestError } = await simulateCallTool(server, 'request_proposals', { + brand: account.brand, + brief: 'social engagement display', + }); + expect(requestError).toBeFalsy(); + expect(requested).not.toHaveProperty('pagination'); + expect(requested).not.toHaveProperty('refinement_applied'); + const source = (requested.proposals as Array>)[0]; + expect(source).toMatchObject({ proposal_status: 'draft' }); + + const { isError: atomicFailure } = await simulateCallTool(server, 'finalize_proposals', { + proposal_ids: [source.proposal_id, 'proposal-not-visible-to-caller'], + }); + expect(atomicFailure).toBe(true); + + const { result: refined, isError: refineError } = await simulateCallTool(server, 'refine_proposals', { + refinements: [{ + proposal_id: source.proposal_id, + instructions: 'Prefer the social inventory while preserving the total budget.', + }, { + proposal_id: 'proposal-not-visible-to-caller', + instructions: 'Use a proposal that is not available in this principal scope.', + }], + }); + expect(refineError).toBeFalsy(); + const refinement = (refined.results as Array>)[0]; + expect(refinement).toMatchObject({ + source_proposal_id: source.proposal_id, + outcome: 'partial', + proposal: { proposal_status: 'draft' }, + }); + const revision = refinement.proposal as Record; + expect(revision.proposal_id).not.toBe(source.proposal_id); + expect((refined.results as Array>)[1]).toMatchObject({ + source_proposal_id: 'proposal-not-visible-to-caller', + outcome: 'unable', + }); + + const { result: finalized, isError: finalizeError } = await simulateCallTool(server, 'finalize_proposals', { + proposal_ids: [revision.proposal_id], + }); + expect(finalizeError).toBeFalsy(); + const committed = (finalized.proposals as Array>)[0]; + expect(committed).toMatchObject({ + proposal_id: revision.proposal_id, + proposal_status: 'committed', + }); + + const { result: purchased, isError: purchaseError } = await simulateCallTool(server, 'create_media_buy', { + account, + brand: account.brand, + start_time: '2027-06-01T00:00:00Z', + end_time: '2027-07-01T00:00:00Z', + proposal_id: committed.proposal_id, + total_budget: { amount: 50000, currency: 'USD' }, + ...(committed.insertion_order && { + io_acceptance: { + io_id: (committed.insertion_order as Record).io_id, + accepted_at: new Date().toISOString(), + signatory: 'compact-lifecycle-test', + }, + }), + }); + expect(purchaseError, JSON.stringify(purchased)).toBeFalsy(); + expect(purchased.media_buy_id).toEqual(expect.any(String)); + }); + + it('binds compact proposals to both the seller account and full BrandKey', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const originalBrand = { domain: 'proposal-house.example', brand_id: 'alpha' }; + const siblingBrand = { domain: 'proposal-house.example', brand_id: 'beta' }; + const { result: requested, isError: requestError } = await simulateCallTool(server, 'request_proposals', { + account_id: 'proposal-account-alpha', + brand: originalBrand, + brief: 'social engagement display', + }); + expect(requestError).toBeFalsy(); + const draft = (requested.proposals as Array>)[0]; + const { result: finalized, isError: finalizeError } = await simulateCallTool(server, 'finalize_proposals', { + proposal_ids: [draft.proposal_id], + }); + expect(finalizeError).toBeFalsy(); + const committed = (finalized.proposals as Array>)[0]; + + const purchase = (billingAccount: string, brand: typeof originalBrand) => simulateCallTool( + server, + 'create_media_buy', + { + account: { account_id: billingAccount }, + brand, + start_time: '2027-06-01T00:00:00Z', + end_time: '2027-07-01T00:00:00Z', + proposal_id: committed.proposal_id, + total_budget: { amount: 50000, currency: 'USD' }, + ...(committed.insertion_order && { + io_acceptance: { + io_id: (committed.insertion_order as Record).io_id, + accepted_at: new Date().toISOString(), + signatory: 'compact-owner-binding-test', + }, + }), + }, + ); + + const wrongBrand = await purchase('proposal-account-alpha', siblingBrand); + expect(wrongBrand).toMatchObject({ isError: true, result: { code: 'PROPOSAL_NOT_FOUND' } }); + + const wrongAccount = await purchase('proposal-account-beta', originalBrand); + expect(wrongAccount).toMatchObject({ isError: true, result: { code: 'PROPOSAL_NOT_FOUND' } }); + + const accepted = await purchase('proposal-account-alpha', originalBrand); + expect(accepted.isError).toBeFalsy(); + expect(accepted.result.media_buy_id).toEqual(expect.any(String)); + }); + + it('rejects an expired draft before atomically finalizing any proposal in the batch', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const requestDraft = async (brief: string) => { + const { result, isError } = await simulateCallTool(server, 'request_proposals', { + brand: account.brand, + brief, + }); + expect(isError).toBeFalsy(); + return (result.proposals as Array>)[0]; + }; + const first = await requestDraft('social engagement display'); + const second = await requestDraft('cross-channel news display'); + const compactSessionKey = sessionKeyFromArgs({}, DEFAULT_CTX.mode, undefined, undefined, 'anonymous'); + await runWithSessionContext(async () => { + const session = await getSession(compactSessionKey); + const expired = session.lastGetProductsContext?.proposals?.find( + proposal => proposal.proposal_id === first.proposal_id, + ); + expect(expired).toBeDefined(); + (expired as unknown as Record).expires_at = '2020-01-01T00:00:00Z'; + await flushDirtySessions(); + }); + + const rejected = await simulateCallTool(server, 'finalize_proposals', { + proposal_ids: [second.proposal_id, first.proposal_id], + }); + expect(rejected).toMatchObject({ isError: true, result: { code: 'PROPOSAL_EXPIRED' } }); + + const session = await getSession(compactSessionKey); + const stillDraft = session.lastGetProductsContext?.proposals?.find( + proposal => proposal.proposal_id === second.proposal_id, + ); + expect(stillDraft?.proposal_status).toBe('draft'); + }); + async function getProductsWithProposals() { const server = createTrainingAgentServer(DEFAULT_CTX); const { result } = await simulateCallTool(server, 'get_products', { diff --git a/static/schemas/source/core/account-authorization.json b/static/schemas/source/core/account-authorization.json index 4b3ddc8686..74024bf9e6 100644 --- a/static/schemas/source/core/account-authorization.json +++ b/static/schemas/source/core/account-authorization.json @@ -7,7 +7,7 @@ "properties": { "allowed_tasks": { "type": "array", - "description": "Canonical snake_case task names the caller may invoke against this account (e.g., get_media_buys, update_media_buy, create_media_buy, sync_creatives). Absence of a task from this list MUST be interpreted as 'not permitted' — invoking an absent task MUST return SCOPE_INSUFFICIENT. This list reflects the caller's grant, not the seller's universal capability surface (for that, see get_adcp_capabilities). A seller may grant narrower subsets to different callers on the same account. AdCP 3.2 product-discovery compatibility: get_products is the authorization name for the full operation family, including every advertised split alias. Sellers normalize aliases before authorization; alias-only grants are not defined during the 3.x compatibility window.", + "description": "Canonical snake_case task names the caller may invoke against this account (e.g., get_media_buys, update_media_buy, create_media_buy, sync_creatives). Absence of a task from this list MUST be interpreted as 'not permitted' — invoking an absent task MUST return SCOPE_INSUFFICIENT. This list reflects the caller's grant, not the seller's universal capability surface. AdCP 3.2 compact product tools are authorized by their own names; a get_products grant does not silently authorize a stateful proposal task.", "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" @@ -16,7 +16,7 @@ }, "field_scopes": { "type": "object", - "description": "Optional per-task allowlist of request fields the caller may set. Keys are task names (which MUST also appear in allowed_tasks). Values are arrays of top-level request-field paths permitted for that task. When a task appears in field_scopes, requests to that task with any field outside the allowlist MUST be rejected with FIELD_NOT_PERMITTED. AdCP 3.2 product-discovery aliases are normalized and checked against the get_products field_scopes entry; alias-specific entries are not defined during the 3.x compatibility window. Implicit framing fields are always permitted and do NOT need to appear in the allowlist — they identify the resource or shape the call rather than mutating business state. The list is non-exhaustive but covers the common cases: typed entity references (`account`, `media_buy_id`, `package_id`, `creative_id`, `signal_id`, `format_id`, `proposal_id`, `plan_id`, `session_id`), concurrency/idempotency (`revision`, `idempotency_key`), buyer-side correlation (`buyer_ref`, `po_number`), mode flags (`dry_run`), pagination (`pagination`, `cursor`, `max_results`), and envelope fields (`context`, `ext`, `adcp_major_version`, `push_notification_config` — transport-level async receipt, not business state). Any other typed entity-id parameter or query-shaping field on a read task SHOULD be treated as framing and not require listing. Tasks absent from field_scopes have no field-level restriction beyond what the task schema already enforces. An entry with an empty array means 'framing fields only, no business fields' — semantically distinct from the task being absent from field_scopes.", + "description": "Optional per-task allowlist of request fields the caller may set. Keys are task names (which MUST also appear in allowed_tasks). Values are arrays of top-level request-field paths permitted for that task. When a task appears in field_scopes, requests to that task with any field outside the allowlist MUST be rejected with FIELD_NOT_PERMITTED. Compact product tools use their own task names and top-level fields such as criteria and refinements. Implicit framing fields are always permitted and do NOT need to appear in the allowlist — they identify the resource or shape the call rather than mutating business state. Tasks absent from field_scopes have no field-level restriction beyond what the task schema already enforces.", "additionalProperties": { "type": "array", "items": { @@ -44,7 +44,7 @@ }, "read_only": { "type": "boolean", - "description": "Convenience flag. When true, the caller is permitted only non-mutating tasks. Sellers MUST reject any mutation from a read-only caller with READ_ONLY_SCOPE. For the AdCP 3.2 product split, read-only permits list_products but rejects recommend_products, refine_proposal, and finalize_proposals; a legacy get_products call is permitted only when the seller can guarantee the selected arm is a synchronous side-effect-free read. Sellers MAY omit this field; omission is equivalent to `false`. Callers MUST NOT infer read-only from `allowed_tasks` alone — the seller MUST set this explicitly when it applies.", + "description": "Convenience flag. When true, the caller is permitted only non-mutating tasks. Sellers MUST reject any mutation from a read-only caller with READ_ONLY_SCOPE. For the AdCP 3.2 product split, read-only permits list_products but rejects request_proposals, refine_proposals, and finalize_proposals; a legacy get_products call is permitted only when the seller can guarantee the selected arm is a synchronous side-effect-free read. Sellers MAY omit this field; omission is equivalent to `false`. Callers MUST NOT infer read-only from `allowed_tasks` alone — the seller MUST set this explicitly when it applies.", "default": false } }, diff --git a/static/schemas/source/core/brand-key.json b/static/schemas/source/core/brand-key.json new file mode 100644 index 0000000000..e4ea060a00 --- /dev/null +++ b/static/schemas/source/core/brand-key.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/brand-key.json", + "title": "Brand Key", + "description": "Stable brand identity used to resolve the canonical brand manifest. The manifest URL is derived from domain; callers do not send brand assets or per-call brand overrides.", + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Domain that hosts /.well-known/brand.json or is registered for the brand.", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" + }, + "brand_id": { + "$ref": "/schemas/core/brand-id.json", + "description": "Brand within a house-of-brands manifest. Omit for a single-brand domain." + } + }, + "required": ["domain"], + "additionalProperties": false +} diff --git a/static/schemas/source/core/catalog-selection.json b/static/schemas/source/core/catalog-selection.json new file mode 100644 index 0000000000..9073ad126b --- /dev/null +++ b/static/schemas/source/core/catalog-selection.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/catalog-selection.json", + "title": "Catalog Selection", + "description": "Reference and item selectors for a catalog already known to the seller. Feed URLs, mappings, schedules, and inline catalog data belong to sync_catalogs. Different selector fields combine with AND semantics; values within ids, gtins, or tags use OR semantics.", + "type": "object", + "properties": { + "catalog_id": { + "type": "string", + "minLength": 1, + "x-entity": "catalog", + "description": "Seller-known catalog identifier returned by sync_catalogs." + }, + "type": { "$ref": "/schemas/core/catalog.json#/properties/type" }, + "ids": { + "allOf": [{ "$ref": "/schemas/core/catalog.json#/properties/ids" }], + "uniqueItems": true + }, + "gtins": { + "allOf": [{ "$ref": "/schemas/core/catalog.json#/properties/gtins" }], + "uniqueItems": true + }, + "tags": { + "allOf": [{ "$ref": "/schemas/core/catalog.json#/properties/tags" }], + "uniqueItems": true + }, + "category": { "$ref": "/schemas/core/catalog.json#/properties/category" }, + "query": { + "allOf": [{ "$ref": "/schemas/core/catalog.json#/properties/query" }], + "minLength": 1 + } + }, + "required": ["catalog_id"], + "additionalProperties": false +} diff --git a/static/schemas/source/core/product-offer-filters.json b/static/schemas/source/core/product-offer-filters.json new file mode 100644 index 0000000000..90a4832999 --- /dev/null +++ b/static/schemas/source/core/product-offer-filters.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/product-offer-filters.json", + "title": "Product Offer Filters", + "description": "Offer, commercial-fit, availability, and reporting filters for product discovery. Delivery targeting is intentionally outside this offer-only filter object.", + "type": "object", + "properties": { + "delivery_type": { "$ref": "/schemas/core/product-filters.json#/properties/delivery_type" }, + "exclusivity": { "$ref": "/schemas/core/product-filters.json#/properties/exclusivity" }, + "is_fixed_price": { "$ref": "/schemas/core/product-filters.json#/properties/is_fixed_price" }, + "pricing_structures": { "$ref": "/schemas/core/product-filters.json#/properties/pricing_structures" }, + "pricing_currencies": { "$ref": "/schemas/core/product-filters.json#/properties/pricing_currencies" }, + "format_kinds": { "$ref": "/schemas/core/product-filters.json#/properties/format_kinds" }, + "format_option_refs": { "$ref": "/schemas/core/product-filters.json#/properties/format_option_refs" }, + "standard_formats_only": { "$ref": "/schemas/core/product-filters.json#/properties/standard_formats_only" }, + "min_exposures": { "$ref": "/schemas/core/product-filters.json#/properties/min_exposures" }, + "start_date": { "$ref": "/schemas/core/product-filters.json#/properties/start_date" }, + "end_date": { "$ref": "/schemas/core/product-filters.json#/properties/end_date" }, + "budget_range": { "$ref": "/schemas/core/product-filters.json#/properties/budget_range" }, + "channels": { "$ref": "/schemas/core/product-filters.json#/properties/channels" }, + "video_placement_types": { "$ref": "/schemas/core/product-filters.json#/properties/video_placement_types" }, + "audio_distribution_types": { "$ref": "/schemas/core/product-filters.json#/properties/audio_distribution_types" }, + "sponsored_placement_types": { "$ref": "/schemas/core/product-filters.json#/properties/sponsored_placement_types" }, + "social_placement_surfaces": { "$ref": "/schemas/core/product-filters.json#/properties/social_placement_surfaces" }, + "trusted_match": { "$ref": "/schemas/core/product-filters.json#/properties/trusted_match" }, + "required_features": { + "allOf": [{ "$ref": "/schemas/core/media-buy-features.json" }], + "description": "Protocol features the seller must support.", + "x-adcp-schema-uri": "/schemas/core/media-buy-features.json" + }, + "required_performance_standards": { + "allOf": [{ "$ref": "/schemas/core/product-filters.json#/properties/required_performance_standards" }], + "x-adcp-schema-uri": "/schemas/core/product-filters.json#/properties/required_performance_standards" + }, + "required_metrics": { "$ref": "/schemas/core/product-filters.json#/properties/required_metrics" }, + "required_vendor_metrics": { + "allOf": [{ "$ref": "/schemas/core/product-filters.json#/properties/required_vendor_metrics" }], + "x-adcp-schema-uri": "/schemas/core/product-filters.json#/properties/required_vendor_metrics" + }, + "audience_evidence_requirements": { + "allOf": [{ "$ref": "/schemas/core/audience-evidence-requirements.json" }], + "description": "Buyer policy for evaluating published audience evidence.", + "x-adcp-schema-uri": "/schemas/core/audience-evidence-requirements.json" + }, + "ext": { "$ref": "/schemas/core/ext.json" } + }, + "additionalProperties": false +} diff --git a/static/schemas/source/core/proposal.json b/static/schemas/source/core/proposal.json index f549a44164..b9c8477205 100644 --- a/static/schemas/source/core/proposal.json +++ b/static/schemas/source/core/proposal.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/proposal.json", "title": "Proposal", - "description": "A proposed media plan with fixed or seller-optimized budget allocation across products. Represents the publisher's strategic recommendation for how to structure a campaign based on the brief. Proposals are actionable: committed proposals can be executed directly via create_media_buy by providing the proposal_id; draft proposals must first be finalized via get_products refine action 'finalize'.", + "description": "A proposed media plan with fixed or seller-optimized budget allocation across products. Committed proposals execute through create_media_buy(proposal_id); draft proposals commit through finalize_proposals (or the legacy get_products finalize form during 3.x).", "type": "object", "properties": { "proposal_id": { diff --git a/static/schemas/source/core/x-entity-types.json b/static/schemas/source/core/x-entity-types.json index de0a415b4f..9e4c43663f 100644 --- a/static/schemas/source/core/x-entity-types.json +++ b/static/schemas/source/core/x-entity-types.json @@ -57,7 +57,7 @@ "media_buy": "A media buy / campaign. `media_buy_id` across media-buy/* schemas.", "package": "A line item within a media buy. `package_id` across media-buy/* schemas.", "product": "A seller inventory product. `product_id` across media-buy/* and core/product.json.", - "proposal": "A seller-issued draft or committed product proposal. `proposal_id` is returned by product recommendation/refinement flows and consumed by refine_proposal, finalize_proposals, and create_media_buy. Scoped to the issuing seller and account.", + "proposal": "A seller-issued draft or committed media-plan proposal. `proposal_id` is returned by request_proposals or refine_proposals and consumed by refine_proposals, finalize_proposals, and create_media_buy. Scoped to the issuing seller and authenticated principal.", "placement": "A public ad placement within a publisher namespace. Identified by the composite of `publisher_domain` plus `placement_id` in core/placement-ref.json and core/placement.json; legacy single-publisher contexts may omit publisher_domain but the identifier is still placement-scoped, not product-scoped.", "product_pricing_option": "A pricing tier on a seller's inventory product (CPM / CPC / CPCV / etc). `pricing_option_id` inside `core/package.json` and `media-buy/package-request.json`. Scoped to the seller's product rate card — not interchangeable with `vendor_pricing_option`.", "vendor_pricing_option": "A pricing tier offered by a vendor agent (rights agent, signals agent, creative agent, governance agent) for its own services. `pricing_option_id` via `core/vendor-pricing-option.json`, also surfaced in `brand/acquire-rights-*`, `signals/activate-signal-request`, `media-buy/build-creative-response`, and `creative/get-creative-features-response`. Scoped to the issuing agent; not interchangeable with `product_pricing_option`.", diff --git a/static/schemas/source/enums/error-code.json b/static/schemas/source/enums/error-code.json index 059b3133aa..4053d633e2 100644 --- a/static/schemas/source/enums/error-code.json +++ b/static/schemas/source/enums/error-code.json @@ -156,8 +156,8 @@ "SESSION_TERMINATED": "SI session has already been terminated and cannot accept further messages. Recovery: correctable (initiate a new session via si_initiate_session).", "VALIDATION_ERROR": "Request contains invalid field values or violates business rules beyond schema validation. Recovery: correctable (review error details and fix field values).", "PRODUCT_EXPIRED": "One or more referenced products have passed their expires_at timestamp and are no longer available for purchase. Recovery: correctable (re-discover with get_products to find current inventory).", - "PROPOSAL_NOT_COMMITTED": "The referenced proposal has proposal_status 'draft' and cannot be used to create a media buy. Recovery: correctable (finalize the proposal first using get_products with buying_mode 'refine' and action 'finalize').", - "PROPOSAL_NOT_FOUND": "The referenced proposal_id is not recognized by the seller — never finalized, belongs to a different tenant, or evicted from the seller's session cache before consumption. Distinct from `PROPOSAL_EXPIRED` (a known proposal whose `expires_at` window has passed) and `PROPOSAL_NOT_COMMITTED` (a known proposal still in `draft`). Recovery: correctable (re-issue `get_products` with `buying_mode: 'refine'` + `action: 'finalize'` to obtain a current proposal_id, then retry create_media_buy).", + "PROPOSAL_NOT_COMMITTED": "The referenced proposal has proposal_status 'draft' and cannot be used to create a media buy. Recovery: correctable (call finalize_proposals first; the legacy get_products finalize form remains supported throughout 3.x).", + "PROPOSAL_NOT_FOUND": "The referenced proposal_id is not recognized by the seller — never finalized, belongs to a different tenant, or evicted from the seller's session cache before consumption. Distinct from `PROPOSAL_EXPIRED` (a known proposal whose `expires_at` window has passed) and `PROPOSAL_NOT_COMMITTED` (a known proposal still in `draft`). Recovery: correctable (request and finalize a current proposal, then retry create_media_buy).", "MULTI_FINALIZE_UNSUPPORTED": "Returned by sellers that cannot guarantee atomic commit across multiple proposals in a single `get_products` call (multiple `action: 'finalize'` entries in `refine[]` targeting different `proposal_id` values). The buyer's intent — atomic multi-proposal finalize — is structurally well-formed and per spec atomic, but this seller's downstream stack cannot satisfy the atomicity guarantee (e.g., the proposals route to two different ad servers with no 2PC). More specific than `INVALID_REQUEST` so buyers can distinguish 'this seller doesn't support multi-finalize' from 'the request itself is malformed'. Recovery: correctable (sequence the finalize calls one-`proposal_id`-per-call; there is no capability flag for multi-finalize support, so a successful first-attempt is the only positive discovery signal). See [refinement guide § Finalize is exclusive](/docs/media-buy/product-discovery/refinement#finalize-is-exclusive-within-refine).", "IO_REQUIRED": "The committed proposal requires a signed insertion order but no io_acceptance was provided. Recovery: correctable (review the proposal's insertion_order, accept terms, and include io_acceptance on create_media_buy).", "TERMS_REJECTED": "Buyer-proposed measurement_terms were rejected by the seller. The error details SHOULD identify which specific term was rejected and the seller's acceptable range or supported vendors. Recovery: correctable (adjust the proposed terms and retry, or omit measurement_terms to accept the product's defaults).", @@ -412,11 +412,11 @@ }, "PROPOSAL_NOT_COMMITTED": { "recovery": "correctable", - "suggestion": "finalize the proposal first using get_products with buying_mode 'refine' and action 'finalize'" + "suggestion": "finalize the proposal first using finalize_proposals" }, "PROPOSAL_NOT_FOUND": { "recovery": "correctable", - "suggestion": "re-issue get_products with buying_mode 'refine' and action 'finalize' to obtain a current proposal_id, then retry" + "suggestion": "request and finalize a current proposal_id, then retry" }, "MULTI_FINALIZE_UNSUPPORTED": { "recovery": "correctable", diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index 887204c006..1e5286ff82 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -530,6 +530,10 @@ "$ref": "/schemas/core/product-filters.json", "description": "Structured filters for product discovery" }, + "product-offer-filters": { + "$ref": "/schemas/core/product-offer-filters.json", + "description": "Offer-only product filters used by the compact product-discovery tools" + }, "creative-filters": { "$ref": "/schemas/core/creative-filters.json", "description": "Filter criteria for querying creative assets from the centralized library" @@ -599,6 +603,14 @@ "$ref": "/schemas/core/brand-ref.json", "description": "Reference to a brand via house domain + brand_id (like publisher + property_id)" }, + "brand-key": { + "$ref": "/schemas/core/brand-key.json", + "description": "Identity-only brand key for resolving a canonical brand manifest" + }, + "catalog-selection": { + "$ref": "/schemas/core/catalog-selection.json", + "description": "Catalog reference and item selectors without ingestion configuration" + }, "seller-agent-ref": { "$ref": "/schemas/core/seller-agent-ref.json", "description": "Reference to a seller agent by its adagents.json-declared URL. Used on TMP AvailablePackage and echoed on Offer." @@ -1291,6 +1303,14 @@ "media-buy": { "description": "Media buy task request/response schemas", "supporting-schemas": { + "product-discovery-criteria": { + "$ref": "/schemas/media-buy/product-discovery-criteria.json", + "description": "Structured offer, catalog, and policy criteria shared by compact discovery tools" + }, + "proposal-refinement": { + "$ref": "/schemas/media-buy/proposal-refinement.json", + "description": "One immutable proposal revision request" + }, "get-products-rejected": { "$ref": "/schemas/media-buy/get-products-rejected.json", "description": "Terminal business rejection arm for a well-formed get_products brief or refinement" @@ -1309,7 +1329,7 @@ "request": { "$ref": "/schemas/media-buy/get-products-request.json", "deprecated": true, - "description": "AdCP 3.x compatibility request. New 3.2 callers use list_products, recommend_products, refine_proposal, or finalize_proposals." + "description": "AdCP 3.x compatibility request. New 3.2 callers use list_products, request_proposals, refine_proposals, or finalize_proposals." }, "response": { "$ref": "/schemas/media-buy/get-products-response.json", @@ -1320,31 +1340,31 @@ "list-products": { "request": { "$ref": "/schemas/media-buy/list-products-request.json", - "description": "Request parameters for synchronous wholesale product-feed reads" + "description": "Request parameters for synchronous product-offer reads" }, "response": { "$ref": "/schemas/media-buy/list-products-response.json", "description": "Response payload for list_products" } }, - "recommend-products": { + "request-proposals": { "request": { - "$ref": "/schemas/media-buy/recommend-products-request.json", - "description": "Request parameters for curated brief-based product recommendations" + "$ref": "/schemas/media-buy/request-proposals-request.json", + "description": "Request parameters for creating actionable seller proposals" }, "response": { - "$ref": "/schemas/media-buy/recommend-products-response.json", - "description": "Response payload for recommend_products" + "$ref": "/schemas/media-buy/request-proposals-response.json", + "description": "Response payload for request_proposals" } }, - "refine-proposal": { + "refine-proposals": { "request": { - "$ref": "/schemas/media-buy/refine-proposal-request.json", - "description": "Request parameters for changing one draft proposal" + "$ref": "/schemas/media-buy/refine-proposals-request.json", + "description": "Request parameters for creating one or more proposal revisions" }, "response": { - "$ref": "/schemas/media-buy/refine-proposal-response.json", - "description": "Response payload for refine_proposal" + "$ref": "/schemas/media-buy/refine-proposals-response.json", + "description": "Response payload for refine_proposals" } }, "finalize-proposals": { diff --git a/static/schemas/source/media-buy/create-media-buy-request.json b/static/schemas/source/media-buy/create-media-buy-request.json index 74f5b0dc90..8a04f869ab 100644 --- a/static/schemas/source/media-buy/create-media-buy-request.json +++ b/static/schemas/source/media-buy/create-media-buy-request.json @@ -72,7 +72,7 @@ }, "proposal_id": { "type": "string", - "description": "ID of a committed proposal from get_products to execute. With total_budget, the publisher creates packages using the proposal's fixed percentages or seller-optimized constraints. Alternative to providing packages. If the referenced proposal has proposal_status: 'draft', the seller MUST reject with PROPOSAL_NOT_COMMITTED; the buyer finalizes first via get_products refine action 'finalize'." + "description": "ID of a committed proposal from finalize_proposals to execute. With total_budget, the publisher creates packages using the proposal's fixed percentages or seller-optimized constraints. Alternative to providing packages. If the referenced proposal has proposal_status: 'draft', the seller MUST reject with PROPOSAL_NOT_COMMITTED; the buyer calls finalize_proposals first. The legacy get_products finalize form remains supported throughout AdCP 3.x." }, "total_budget": { "type": "object", diff --git a/static/schemas/source/media-buy/finalize-proposals-async-response-input-required.json b/static/schemas/source/media-buy/finalize-proposals-async-response-input-required.json deleted file mode 100644 index 63877ee3d9..0000000000 --- a/static/schemas/source/media-buy/finalize-proposals-async-response-input-required.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/media-buy/finalize-proposals-async-response-input-required.json", - "title": "Finalize Proposals Input Required Response", - "description": "Input-required payload for paused atomic proposal finalization.", - "allOf": [{ "$ref": "/schemas/media-buy/get-products-async-response-input-required.json" }] -} diff --git a/static/schemas/source/media-buy/finalize-proposals-async-response-submitted.json b/static/schemas/source/media-buy/finalize-proposals-async-response-submitted.json deleted file mode 100644 index 3729f77950..0000000000 --- a/static/schemas/source/media-buy/finalize-proposals-async-response-submitted.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/media-buy/finalize-proposals-async-response-submitted.json", - "title": "Finalize Proposals Submitted Response", - "description": "Submitted task envelope for asynchronous atomic proposal finalization.", - "allOf": [{ "$ref": "/schemas/media-buy/get-products-async-response-submitted.json" }] -} diff --git a/static/schemas/source/media-buy/finalize-proposals-async-response-working.json b/static/schemas/source/media-buy/finalize-proposals-async-response-working.json deleted file mode 100644 index 971579207d..0000000000 --- a/static/schemas/source/media-buy/finalize-proposals-async-response-working.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/media-buy/finalize-proposals-async-response-working.json", - "title": "Finalize Proposals Working Response", - "description": "Progress payload for active atomic proposal finalization.", - "allOf": [{ "$ref": "/schemas/media-buy/get-products-async-response-working.json" }] -} diff --git a/static/schemas/source/media-buy/finalize-proposals-request.json b/static/schemas/source/media-buy/finalize-proposals-request.json index eb7eef6de3..4ecefbefe1 100644 --- a/static/schemas/source/media-buy/finalize-proposals-request.json +++ b/static/schemas/source/media-buy/finalize-proposals-request.json @@ -2,73 +2,40 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/finalize-proposals-request.json", "title": "Finalize Proposals Request", - "description": "AdCP 3.2 additive request for atomically committing one or more draft proposals to firm pricing and inventory holds. It replaces exclusive get_products refine entries with action: finalize.", + "description": "Atomically commit one or more draft proposals to firm terms and inventory holds. Proposal ownership is resolved from the authenticated principal and opaque IDs.", "type": "object", "x-mutates-state": true, - "x-operation-family": "get_products", + "x-operation-family": "finalize_proposals", "x-added-in": "3.2.0", - "allOf": [ - { - "$ref": "/schemas/core/version-envelope.json" - }, - { - "not": { - "anyOf": [ - { "required": ["buying_mode"] }, - { "required": ["brief"] }, - { "required": ["refine"] }, - { "required": ["catalog"] }, - { "required": ["filters"] }, - { "required": ["fields"] }, - { "required": ["preferred_delivery_types"] }, - { "required": ["property_list"] }, - { "required": ["required_policies"] }, - { "required": ["if_wholesale_feed_version"] }, - { "required": ["if_pricing_version"] }, - { "required": ["pagination"] }, - { "required": ["time_budget"] } - ] - } - } - ], "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, + "adcp_major_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_major_version" }, + "context_id": { "type": "string", "minLength": 1 }, + "context": { + "allOf": [{ "$ref": "/schemas/core/context.json" }], + "x-adcp-schema-uri": "/schemas/core/context.json" + }, + "governance_context": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "push_notification_config": { + "allOf": [{ "$ref": "/schemas/core/push-notification-config.json" }], + "x-adcp-schema-uri": "/schemas/core/push-notification-config.json" + }, "idempotency_key": { "type": "string", - "description": "Client-generated key required for retry-safe atomic proposal finalization.", + "description": "Client-generated key required for retry-safe atomic finalization.", "minLength": 16, "maxLength": 255, "pattern": "^[A-Za-z0-9_.:-]{16,255}$" }, "proposal_ids": { "type": "array", + "description": "Unique proposal IDs to commit as one atomic operation.", + "items": { "type": "string", "minLength": 1, "x-entity": "proposal" }, "minItems": 1, - "uniqueItems": true, - "items": { - "type": "string", - "minLength": 1, - "x-entity": "proposal" - }, - "description": "Proposal IDs to commit. Multi-proposal requests retain the atomic pre-commit validation contract of legacy get_products finalization." - }, - "account": { - "$ref": "/schemas/core/account-ref.json" - }, - "brand": { - "$ref": "/schemas/core/brand-ref.json" - }, - "context": { - "$ref": "/schemas/core/context.json" - }, - "push_notification_config": { - "$ref": "/schemas/core/push-notification-config.json" - }, - "ext": { - "$ref": "/schemas/core/ext.json" + "maxItems": 25, + "uniqueItems": true } }, - "required": [ - "idempotency_key", - "proposal_ids" - ], - "additionalProperties": true + "required": ["idempotency_key", "proposal_ids"], + "additionalProperties": false } diff --git a/static/schemas/source/media-buy/finalize-proposals-response.json b/static/schemas/source/media-buy/finalize-proposals-response.json index 186396683a..0e43a7f274 100644 --- a/static/schemas/source/media-buy/finalize-proposals-response.json +++ b/static/schemas/source/media-buy/finalize-proposals-response.json @@ -2,6 +2,33 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/finalize-proposals-response.json", "title": "Finalize Proposals Response", - "description": "Proposal-finalization response. The wire shape is intentionally identical to an exclusive get_products finalize response during the AdCP 3.2 alias window.", - "allOf": [{ "$ref": "/schemas/media-buy/get-products-response.json" }] + "description": "Committed proposal snapshots returned only after every named proposal has been persisted atomically.", + "type": "object", + "properties": { + "proposals": { + "type": "array", + "minItems": 1, + "items": { + "allOf": [ + { "$ref": "/schemas/core/proposal.json" }, + { + "properties": { + "proposal_status": { "type": "string", "const": "committed" }, + "expires_at": { "type": "string", "format": "date-time" } + }, + "required": ["proposal_status", "expires_at"] + } + ] + } + } + }, + "required": ["proposals"], + "not": { + "anyOf": [ + { "required": ["products"] }, + { "required": ["refinement_applied"] }, + { "required": ["pagination"] } + ] + }, + "additionalProperties": true } diff --git a/static/schemas/source/media-buy/get-products-request.json b/static/schemas/source/media-buy/get-products-request.json index 3394585030..ca15495d8d 100644 --- a/static/schemas/source/media-buy/get-products-request.json +++ b/static/schemas/source/media-buy/get-products-request.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/get-products-request.json", "title": "Get Products Request", - "description": "AdCP 3.x compatibility request for discovering, refining, or finalizing advertising products and proposals. AdCP 3.2 introduces the narrower list_products, recommend_products, refine_proposal, and finalize_proposals tasks; new callers SHOULD use those tasks. Existing get_products payloads remain valid throughout 3.x.", + "description": "AdCP 3.x compatibility request for discovering, refining, or finalizing advertising products and proposals. AdCP 3.2 introduces the narrower list_products, request_proposals, refine_proposals, and finalize_proposals tasks; new callers SHOULD use those tasks. Existing get_products payloads remain valid throughout 3.x.", "type": "object", "allOf": [ { @@ -43,7 +43,7 @@ "properties": { "idempotency_key": { "type": "string", - "description": "Optional client-generated key for retry-safe use of the AdCP 3.x compatibility facade. Sellers MUST honor replay semantics when the key is supplied, including across the equivalent AdCP 3.2 split-task alias. New callers SHOULD use list_products, recommend_products, refine_proposal, or finalize_proposals; the three stateful split tasks require a key. Keys MUST be unique per (seller, logical request) pair to prevent cross-seller correlation.", + "description": "Optional client-generated key for retry-safe use of the AdCP 3.x compatibility facade. New callers SHOULD use the compact 3.2 tasks; each stateful split task has its own idempotency identity, so callers MUST retry with the same tool name. Keys MUST be unique per seller and logical request.", "minLength": 16, "maxLength": 255, "pattern": "^[A-Za-z0-9_.:-]{16,255}$" diff --git a/static/schemas/source/media-buy/list-products-request.json b/static/schemas/source/media-buy/list-products-request.json index b571a43d2b..758c99b6ab 100644 --- a/static/schemas/source/media-buy/list-products-request.json +++ b/static/schemas/source/media-buy/list-products-request.json @@ -2,94 +2,61 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/list-products-request.json", "title": "List Products Request", - "description": "AdCP 3.2 additive wholesale product-feed request. This is the synchronous, side-effect-free replacement for get_products with buying_mode: wholesale. The legacy get_products form remains supported throughout 3.x.", + "description": "Read seller offers with structured discovery criteria. This task is synchronous and side-effect free; proposal creation is handled by request_proposals.", "type": "object", - "x-operation-family": "get_products", + "x-operation-family": "list_products", "x-added-in": "3.2.0", - "allOf": [ - { - "$ref": "/schemas/core/version-envelope.json" - }, - { - "if": { - "required": [ - "if_pricing_version" - ] - }, - "then": { - "required": [ - "if_wholesale_feed_version" - ] - } - }, - { - "not": { - "anyOf": [ - { "required": ["buying_mode"] }, - { "required": ["brief"] }, - { "required": ["refine"] } - ] - } - } - ], "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, + "adcp_major_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_major_version" }, "idempotency_key": { "type": "string", - "description": "Optional client-generated key. When supplied, sellers MUST honor replay semantics across list_products and the equivalent legacy get_products request.", "minLength": 16, "maxLength": 255, - "pattern": "^[A-Za-z0-9_.:-]{16,255}$" - }, - "account": { - "$ref": "/schemas/core/account-ref.json" - }, - "brand": { - "$ref": "/schemas/core/brand-ref.json" - }, - "catalog": { - "$ref": "/schemas/core/catalog.json" + "pattern": "^[A-Za-z0-9_.:-]{16,255}$", + "description": "Optional replay key accepted uniformly on read calls." }, + "context_id": { "type": "string", "minLength": 1 }, "context": { - "$ref": "/schemas/core/context.json" - }, - "ext": { - "$ref": "/schemas/core/ext.json" - }, - "fields": { - "$ref": "/schemas/media-buy/product-fields.json" + "allOf": [{ "$ref": "/schemas/core/context.json" }], + "x-adcp-schema-uri": "/schemas/core/context.json" }, - "filters": { - "$ref": "/schemas/core/product-filters.json" + "governance_context": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "push_notification_config": { + "allOf": [{ "$ref": "/schemas/core/push-notification-config.json" }], + "x-adcp-schema-uri": "/schemas/core/push-notification-config.json" }, - "if_wholesale_feed_version": { + "account_id": { + "type": "string", + "minLength": 1, + "x-entity": "account", + "description": "Seller-known account whose pricing or availability overlay should be applied." + }, + "brand": { "$ref": "/schemas/core/brand-key.json" }, + "criteria": { "$ref": "/schemas/media-buy/product-discovery-criteria.json" }, + "fields": { "$ref": "/schemas/media-buy/product-fields.json" }, + "cursor": { "type": "string", "minLength": 1 }, + "max_results": { "type": "integer", "minimum": 1, "maximum": 100, "default": 25 }, + "if_feed_version": { "type": "string", - "description": "Opaque wholesale feed version token returned by a prior list_products or equivalent get_products response." + "description": "Opaque feed version returned by a prior list_products response." }, "if_pricing_version": { "type": "string", - "description": "Opaque pricing version token. Valid only with if_wholesale_feed_version." - }, - "pagination": { - "$ref": "/schemas/core/pagination-request.json" - }, - "property_list": { - "$ref": "/schemas/core/property-list-ref.json" - }, - "push_notification_config": { - "$ref": "/schemas/core/push-notification-config.json", - "description": "Envelope-level callback configuration accepted for wrapper portability. list_products is synchronous and ignores this field." - }, - "required_policies": { - "type": "array", - "description": "Registry policy IDs that the buyer requires products to enforce.", - "items": { "type": "string" } - }, - "time_budget": { - "$ref": "/schemas/core/duration.json" + "description": "Opaque pricing version returned by a prior list_products response. Valid only with if_feed_version." } }, "dependencies": { - "catalog": ["brand"] + "if_pricing_version": ["if_feed_version"] }, - "additionalProperties": true + "allOf": [ + { + "if": { + "properties": { "criteria": { "required": ["catalog"] } }, + "required": ["criteria"] + }, + "then": { "required": ["brand"] } + } + ], + "additionalProperties": false } diff --git a/static/schemas/source/media-buy/list-products-response.json b/static/schemas/source/media-buy/list-products-response.json index 3549e65b5a..ee75bbcbe0 100644 --- a/static/schemas/source/media-buy/list-products-response.json +++ b/static/schemas/source/media-buy/list-products-response.json @@ -2,6 +2,40 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/list-products-response.json", "title": "List Products Response", - "description": "Wholesale product-feed response. The wire shape is intentionally identical to the completed wholesale arm of get_products during the AdCP 3.2 alias window.", - "allOf": [{ "$ref": "/schemas/media-buy/get-products-response.json" }] + "description": "Product offers and continuation state. This response never contains proposals or proposal-lifecycle fields.", + "type": "object", + "properties": { + "products": { + "type": "array", + "items": { "$ref": "/schemas/core/product.json" } + }, + "next_cursor": { + "type": "string", + "minLength": 1, + "description": "Cursor for the next page. Omitted when this is the last page." + }, + "feed_version": { "type": "string", "description": "Opaque version of the selected offer feed." }, + "pricing_version": { "type": "string", "description": "Opaque version of the selected pricing layer." }, + "cache_scope": { "type": "string", "enum": ["public", "account"] }, + "unchanged": { "type": "boolean", "const": true }, + "incomplete": { "$ref": "/schemas/media-buy/get-products-response.json#/properties/incomplete" } + }, + "oneOf": [ + { + "required": ["products"], + "not": { "required": ["unchanged"] } + }, + { + "required": ["unchanged", "feed_version", "cache_scope"], + "not": { "required": ["products"] } + } + ], + "not": { + "anyOf": [ + { "required": ["proposals"] }, + { "required": ["refinement_applied"] }, + { "required": ["pagination"] } + ] + }, + "additionalProperties": true } diff --git a/static/schemas/source/media-buy/product-discovery-criteria.json b/static/schemas/source/media-buy/product-discovery-criteria.json new file mode 100644 index 0000000000..cf521e2072 --- /dev/null +++ b/static/schemas/source/media-buy/product-discovery-criteria.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/product-discovery-criteria.json", + "title": "Product Discovery Criteria", + "description": "Structured criteria shared by product listing and proposal requests.", + "type": "object", + "properties": { + "product_ids": { + "type": "array", + "description": "Exact seller-issued products to retrieve or use as proposal candidates.", + "items": { "type": "string", "minLength": 1, "x-entity": "product" }, + "minItems": 1, + "uniqueItems": true + }, + "offer_filters": { "$ref": "/schemas/core/product-offer-filters.json" }, + "catalog": { "$ref": "/schemas/core/catalog-selection.json" }, + "policy_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "uniqueItems": true + }, + "ext": { "type": "object", "additionalProperties": true } + }, + "minProperties": 1, + "additionalProperties": false +} diff --git a/static/schemas/source/media-buy/proposal-refinement.json b/static/schemas/source/media-buy/proposal-refinement.json new file mode 100644 index 0000000000..a7b0c7bd69 --- /dev/null +++ b/static/schemas/source/media-buy/proposal-refinement.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/proposal-refinement.json", + "title": "Proposal Refinement", + "description": "Requested revision of one draft proposal. A successful refinement produces a new draft proposal and leaves the source proposal unchanged.", + "type": "object", + "properties": { + "proposal_id": { + "type": "string", + "minLength": 1, + "x-entity": "proposal" + }, + "instructions": { + "type": "string", + "minLength": 1, + "description": "Natural-language changes that are not represented by structured criteria." + } + }, + "required": ["proposal_id", "instructions"], + "additionalProperties": false +} diff --git a/static/schemas/source/media-buy/recommend-products-async-response-input-required.json b/static/schemas/source/media-buy/recommend-products-async-response-input-required.json deleted file mode 100644 index 9e0dd7bd69..0000000000 --- a/static/schemas/source/media-buy/recommend-products-async-response-input-required.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/media-buy/recommend-products-async-response-input-required.json", - "title": "Recommend Products Input Required Response", - "description": "Input-required payload for paused curated product recommendation.", - "allOf": [{ "$ref": "/schemas/media-buy/get-products-async-response-input-required.json" }] -} diff --git a/static/schemas/source/media-buy/recommend-products-async-response-submitted.json b/static/schemas/source/media-buy/recommend-products-async-response-submitted.json deleted file mode 100644 index 2c6e3516e2..0000000000 --- a/static/schemas/source/media-buy/recommend-products-async-response-submitted.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/media-buy/recommend-products-async-response-submitted.json", - "title": "Recommend Products Submitted Response", - "description": "Submitted task envelope for asynchronous curated product recommendation.", - "allOf": [{ "$ref": "/schemas/media-buy/get-products-async-response-submitted.json" }] -} diff --git a/static/schemas/source/media-buy/recommend-products-async-response-working.json b/static/schemas/source/media-buy/recommend-products-async-response-working.json deleted file mode 100644 index c59f657a92..0000000000 --- a/static/schemas/source/media-buy/recommend-products-async-response-working.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/media-buy/recommend-products-async-response-working.json", - "title": "Recommend Products Working Response", - "description": "Progress payload for active curated product recommendation.", - "allOf": [{ "$ref": "/schemas/media-buy/get-products-async-response-working.json" }] -} diff --git a/static/schemas/source/media-buy/recommend-products-request.json b/static/schemas/source/media-buy/recommend-products-request.json deleted file mode 100644 index 35c0fc9aa5..0000000000 --- a/static/schemas/source/media-buy/recommend-products-request.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/media-buy/recommend-products-request.json", - "title": "Recommend Products Request", - "description": "AdCP 3.2 additive curated product-discovery request. This replaces get_products with buying_mode: brief. It may allocate an asynchronous task, so idempotency_key is required even when a seller completes synchronously.", - "type": "object", - "x-mutates-state": true, - "x-operation-family": "get_products", - "x-added-in": "3.2.0", - "allOf": [ - { - "$ref": "/schemas/core/version-envelope.json" - }, - { - "not": { - "anyOf": [ - { "required": ["buying_mode"] }, - { "required": ["refine"] }, - { "required": ["if_wholesale_feed_version"] }, - { "required": ["if_pricing_version"] } - ] - } - } - ], - "properties": { - "idempotency_key": { - "type": "string", - "description": "Client-generated key required for retry-safe curated discovery.", - "minLength": 16, - "maxLength": 255, - "pattern": "^[A-Za-z0-9_.:-]{16,255}$" - }, - "brief": { - "type": "string", - "minLength": 1, - "description": "Natural-language campaign requirements used to curate recommendations." - }, - "account": { - "$ref": "/schemas/core/account-ref.json" - }, - "brand": { - "$ref": "/schemas/core/brand-ref.json" - }, - "catalog": { - "$ref": "/schemas/core/catalog.json" - }, - "context": { - "$ref": "/schemas/core/context.json" - }, - "ext": { - "$ref": "/schemas/core/ext.json" - }, - "fields": { - "$ref": "/schemas/media-buy/product-fields.json" - }, - "filters": { - "$ref": "/schemas/core/product-filters.json" - }, - "pagination": { - "$ref": "/schemas/core/pagination-request.json" - }, - "preferred_delivery_types": { - "type": "array", - "description": "Preferred delivery types in priority order.", - "minItems": 1, - "uniqueItems": true, - "items": { "$ref": "/schemas/enums/delivery-type.json" } - }, - "property_list": { - "$ref": "/schemas/core/property-list-ref.json" - }, - "push_notification_config": { - "$ref": "/schemas/core/push-notification-config.json" - }, - "required_policies": { - "type": "array", - "description": "Registry policy IDs that the buyer requires products to enforce.", - "items": { "type": "string" } - }, - "time_budget": { - "$ref": "/schemas/core/duration.json" - } - }, - "required": [ - "idempotency_key", - "brief" - ], - "dependencies": { - "catalog": ["brand"] - }, - "additionalProperties": true -} diff --git a/static/schemas/source/media-buy/recommend-products-response.json b/static/schemas/source/media-buy/recommend-products-response.json deleted file mode 100644 index 7fae29f9f5..0000000000 --- a/static/schemas/source/media-buy/recommend-products-response.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/media-buy/recommend-products-response.json", - "title": "Recommend Products Response", - "description": "Curated discovery response. The wire shape is intentionally identical to the brief arm of get_products during the AdCP 3.2 alias window.", - "allOf": [{ "$ref": "/schemas/media-buy/get-products-response.json" }] -} diff --git a/static/schemas/source/media-buy/refine-proposal-async-response-input-required.json b/static/schemas/source/media-buy/refine-proposal-async-response-input-required.json deleted file mode 100644 index 52b77a2832..0000000000 --- a/static/schemas/source/media-buy/refine-proposal-async-response-input-required.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/media-buy/refine-proposal-async-response-input-required.json", - "title": "Refine Proposal Input Required Response", - "description": "Input-required payload for paused proposal refinement.", - "allOf": [{ "$ref": "/schemas/media-buy/get-products-async-response-input-required.json" }] -} diff --git a/static/schemas/source/media-buy/refine-proposal-async-response-submitted.json b/static/schemas/source/media-buy/refine-proposal-async-response-submitted.json deleted file mode 100644 index d073fa5ea8..0000000000 --- a/static/schemas/source/media-buy/refine-proposal-async-response-submitted.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/media-buy/refine-proposal-async-response-submitted.json", - "title": "Refine Proposal Submitted Response", - "description": "Submitted task envelope for asynchronous proposal refinement.", - "allOf": [{ "$ref": "/schemas/media-buy/get-products-async-response-submitted.json" }] -} diff --git a/static/schemas/source/media-buy/refine-proposal-async-response-working.json b/static/schemas/source/media-buy/refine-proposal-async-response-working.json deleted file mode 100644 index 4e9370d512..0000000000 --- a/static/schemas/source/media-buy/refine-proposal-async-response-working.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/media-buy/refine-proposal-async-response-working.json", - "title": "Refine Proposal Working Response", - "description": "Progress payload for active proposal refinement.", - "allOf": [{ "$ref": "/schemas/media-buy/get-products-async-response-working.json" }] -} diff --git a/static/schemas/source/media-buy/refine-proposal-request.json b/static/schemas/source/media-buy/refine-proposal-request.json deleted file mode 100644 index a5662f3ba2..0000000000 --- a/static/schemas/source/media-buy/refine-proposal-request.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/media-buy/refine-proposal-request.json", - "title": "Refine Proposal Request", - "description": "AdCP 3.2 additive request for changing one draft proposal. It preserves the get_products refine grammar, but excludes finalization and requires every proposal-scoped entry to name the same proposal. Finalization is a separate finalize_proposals call.", - "type": "object", - "x-mutates-state": true, - "x-operation-family": "get_products", - "x-added-in": "3.2.0", - "allOf": [ - { - "$ref": "/schemas/core/version-envelope.json" - }, - { - "not": { - "anyOf": [ - { "required": ["buying_mode"] }, - { "required": ["brief"] }, - { "required": ["if_wholesale_feed_version"] }, - { "required": ["if_pricing_version"] } - ] - } - } - ], - "properties": { - "idempotency_key": { - "type": "string", - "description": "Client-generated key required for retry-safe proposal refinement.", - "minLength": 16, - "maxLength": 255, - "pattern": "^[A-Za-z0-9_.:-]{16,255}$" - }, - "refine": { - "allOf": [ - { "$ref": "/schemas/media-buy/product-refinement.json" }, - { - "contains": { - "type": "object", - "properties": { "scope": { "const": "proposal" } }, - "required": ["scope"] - } - }, - { - "not": { - "contains": { - "type": "object", - "properties": { "action": { "const": "finalize" } }, - "required": ["action"] - } - } - } - ], - "description": "Non-finalizing request, product, and proposal changes using the established get_products refine grammar. At least one entry MUST be proposal-scoped, all proposal-scoped entries MUST name the same proposal_id, and action: finalize is invalid on this tool." - }, - "account": { - "$ref": "/schemas/core/account-ref.json" - }, - "brand": { - "$ref": "/schemas/core/brand-ref.json" - }, - "catalog": { - "$ref": "/schemas/core/catalog.json" - }, - "context": { - "$ref": "/schemas/core/context.json" - }, - "ext": { - "$ref": "/schemas/core/ext.json" - }, - "fields": { - "$ref": "/schemas/media-buy/product-fields.json" - }, - "filters": { - "$ref": "/schemas/core/product-filters.json" - }, - "pagination": { - "$ref": "/schemas/core/pagination-request.json" - }, - "preferred_delivery_types": { - "type": "array", - "description": "Preferred delivery types in priority order.", - "minItems": 1, - "uniqueItems": true, - "items": { "$ref": "/schemas/enums/delivery-type.json" } - }, - "property_list": { - "$ref": "/schemas/core/property-list-ref.json" - }, - "push_notification_config": { - "$ref": "/schemas/core/push-notification-config.json" - }, - "required_policies": { - "type": "array", - "description": "Registry policy IDs that the buyer requires products to enforce.", - "items": { "type": "string" } - }, - "time_budget": { - "$ref": "/schemas/core/duration.json" - } - }, - "required": [ - "idempotency_key", - "refine" - ], - "dependencies": { - "catalog": ["brand"] - }, - "additionalProperties": true -} diff --git a/static/schemas/source/media-buy/refine-proposal-response.json b/static/schemas/source/media-buy/refine-proposal-response.json deleted file mode 100644 index d4ea3ee207..0000000000 --- a/static/schemas/source/media-buy/refine-proposal-response.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/media-buy/refine-proposal-response.json", - "title": "Refine Proposal Response", - "description": "Proposal-refinement response. The wire shape is intentionally identical to a non-finalizing get_products refine response during the AdCP 3.2 alias window.", - "allOf": [{ "$ref": "/schemas/media-buy/get-products-response.json" }] -} diff --git a/static/schemas/source/media-buy/refine-proposals-request.json b/static/schemas/source/media-buy/refine-proposals-request.json new file mode 100644 index 0000000000..57b541a89f --- /dev/null +++ b/static/schemas/source/media-buy/refine-proposals-request.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/refine-proposals-request.json", + "title": "Refine Proposals Request", + "description": "Create revised drafts from one or more existing proposals. Each source proposal remains addressable so buyers can explore alternatives safely.", + "type": "object", + "x-mutates-state": true, + "x-operation-family": "refine_proposals", + "x-added-in": "3.2.0", + "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, + "adcp_major_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_major_version" }, + "context_id": { "type": "string", "minLength": 1 }, + "context": { + "allOf": [{ "$ref": "/schemas/core/context.json" }], + "x-adcp-schema-uri": "/schemas/core/context.json" + }, + "governance_context": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "push_notification_config": { + "allOf": [{ "$ref": "/schemas/core/push-notification-config.json" }], + "x-adcp-schema-uri": "/schemas/core/push-notification-config.json" + }, + "idempotency_key": { + "type": "string", + "description": "Client-generated key required for retry-safe proposal refinement.", + "minLength": 16, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{16,255}$" + }, + "refinements": { + "type": "array", + "description": "Proposal revisions to create. proposal_id values MUST be unique within the request; results preserve request order.", + "items": { "$ref": "/schemas/media-buy/proposal-refinement.json" }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": ["idempotency_key", "refinements"], + "additionalProperties": false +} diff --git a/static/schemas/source/media-buy/refine-proposals-response.json b/static/schemas/source/media-buy/refine-proposals-response.json new file mode 100644 index 0000000000..9e45389e3a --- /dev/null +++ b/static/schemas/source/media-buy/refine-proposals-response.json @@ -0,0 +1,83 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/refine-proposals-response.json", + "title": "Refine Proposals Response", + "description": "One ordered immutable revision result per requested source proposal, plus the products needed to evaluate the revised allocations.", + "type": "object", + "properties": { + "results": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "source_proposal_id": { + "type": "string", + "minLength": 1, + "x-entity": "proposal" + }, + "outcome": { + "type": "string", + "enum": ["revised", "partial", "unable"] + }, + "proposal": { + "allOf": [ + { "$ref": "/schemas/core/proposal.json" }, + { + "properties": { + "proposal_status": { "type": "string", "const": "draft" }, + "expires_at": { "type": "string", "format": "date-time" } + }, + "required": ["proposal_status", "expires_at"] + } + ] + }, + "reason": { "type": "string", "minLength": 1 }, + "notes": { "type": "string", "minLength": 1 }, + "suggestions": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1 + } + }, + "required": ["source_proposal_id", "outcome"], + "oneOf": [ + { + "properties": { "outcome": { "const": "revised" } }, + "required": ["proposal"], + "not": { + "anyOf": [ + { "required": ["reason"] }, + { "required": ["notes"] } + ] + } + }, + { + "properties": { "outcome": { "const": "partial" } }, + "required": ["proposal", "notes"], + "not": { "required": ["reason"] } + }, + { + "properties": { "outcome": { "const": "unable" } }, + "required": ["reason"], + "not": { "required": ["proposal"] } + } + ], + "additionalProperties": false + } + }, + "products": { + "type": "array", + "items": { "$ref": "/schemas/core/product.json" } + } + }, + "required": ["results", "products"], + "not": { + "anyOf": [ + { "required": ["proposals"] }, + { "required": ["refinement_applied"] }, + { "required": ["pagination"] } + ] + }, + "additionalProperties": true +} diff --git a/static/schemas/source/media-buy/request-proposals-request.json b/static/schemas/source/media-buy/request-proposals-request.json new file mode 100644 index 0000000000..583cc37e7c --- /dev/null +++ b/static/schemas/source/media-buy/request-proposals-request.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/request-proposals-request.json", + "title": "Request Proposals Request", + "description": "Ask a seller to create one or more actionable media-plan proposals from a brief. Proposal IDs returned here connect refinement, finalization, and purchase.", + "type": "object", + "x-mutates-state": true, + "x-operation-family": "request_proposals", + "x-added-in": "3.2.0", + "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, + "adcp_major_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_major_version" }, + "context_id": { "type": "string", "minLength": 1 }, + "context": { + "allOf": [{ "$ref": "/schemas/core/context.json" }], + "x-adcp-schema-uri": "/schemas/core/context.json" + }, + "governance_context": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "push_notification_config": { + "allOf": [{ "$ref": "/schemas/core/push-notification-config.json" }], + "x-adcp-schema-uri": "/schemas/core/push-notification-config.json" + }, + "idempotency_key": { + "type": "string", + "description": "Client-generated key required for retry-safe proposal creation.", + "minLength": 16, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{16,255}$" + }, + "account_id": { + "type": "string", + "minLength": 1, + "x-entity": "account", + "description": "Seller-known account for account-specific terms." + }, + "brand": { "$ref": "/schemas/core/brand-key.json" }, + "brief": { + "type": "string", + "minLength": 1, + "description": "Campaign goal, strategy, and requirements that are not represented in structured criteria." + }, + "criteria": { "$ref": "/schemas/media-buy/product-discovery-criteria.json" } + }, + "required": ["idempotency_key", "brand", "brief"], + "additionalProperties": false +} diff --git a/static/schemas/source/media-buy/request-proposals-response.json b/static/schemas/source/media-buy/request-proposals-response.json new file mode 100644 index 0000000000..98e99c662f --- /dev/null +++ b/static/schemas/source/media-buy/request-proposals-response.json @@ -0,0 +1,68 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/request-proposals-response.json", + "title": "Request Proposals Response", + "description": "One or more draft media-plan proposals and the exact products referenced by their allocations. Returning only products does not satisfy request_proposals.", + "type": "object", + "properties": { + "outcome": { "type": "string", "const": "rejected" }, + "reason": { "type": "string", "minLength": 1 }, + "suggestions": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1 + }, + "proposals": { + "type": "array", + "minItems": 1, + "items": { + "allOf": [ + { "$ref": "/schemas/core/proposal.json" }, + { + "properties": { + "proposal_status": { "type": "string", "const": "draft" }, + "expires_at": { "type": "string", "format": "date-time" } + }, + "required": ["proposal_status", "expires_at"] + } + ] + } + }, + "products": { + "type": "array", + "minItems": 1, + "items": { "$ref": "/schemas/core/product.json" } + } + }, + "oneOf": [ + { + "required": ["proposals", "products"], + "not": { + "anyOf": [ + { "required": ["outcome"] }, + { "required": ["reason"] }, + { "required": ["suggestions"] } + ] + } + }, + { + "properties": { "outcome": { "type": "string", "const": "rejected" } }, + "required": ["outcome", "reason"], + "not": { + "anyOf": [ + { "required": ["proposals"] }, + { "required": ["products"] } + ] + } + } + ], + "not": { + "anyOf": [ + { "required": ["refinement_applied"] }, + { "required": ["pagination"] }, + { "required": ["unchanged"] }, + { "required": ["wholesale_feed_version"] } + ] + }, + "additionalProperties": true +} diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index 67096f1098..bc149bc3ff 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -11,6 +11,28 @@ { "$ref": "/schemas/core/protocol-envelope.json" }, + { + "if": { + "properties": { + "media_buy": { + "properties": { + "product_discovery_tools": { + "contains": { "const": "finalize_proposals" } + } + }, + "required": ["product_discovery_tools"] + } + }, + "required": ["media_buy"] + }, + "then": { + "properties": { + "media_buy": { + "required": ["max_atomic_finalize_batch_size"] + } + } + } + }, { "if": { "allOf": [ @@ -636,14 +658,20 @@ }, "product_discovery_tools": { "type": "array", - "description": "Product-discovery operation names this seller supports. Added in AdCP 3.2 for the additive split of get_products. Sellers may advertise any supported subset of list_products, recommend_products, refine_proposal, and finalize_proposals while retaining get_products throughout 3.x. Absence means the caller should use the legacy get_products facade and inspect buying_modes. Every advertised split tool shares get_products authorization, idempotency, task-recovery, and webhook operation identity during 3.x.", + "description": "Product-discovery operation names this seller supports. Added in AdCP 3.2 as compact, task-specific contracts that form the 4.0 lifecycle foundation. Sellers may advertise any supported subset while retaining get_products throughout 3.x. Absence means the caller uses the legacy facade. Each stateful split task has its own idempotency identity; callers MUST retry with the same tool name.", "items": { "type": "string", - "enum": ["get_products", "list_products", "recommend_products", "refine_proposal", "finalize_proposals"] + "enum": ["get_products", "list_products", "request_proposals", "refine_proposals", "finalize_proposals"] }, "minItems": 1, "uniqueItems": true }, + "max_atomic_finalize_batch_size": { + "type": "integer", + "minimum": 1, + "maximum": 25, + "description": "Maximum proposal_ids length the seller can commit atomically in finalize_proposals. Sellers supporting only single-proposal finalization declare 1. Required when finalize_proposals is advertised." + }, "reporting_delivery_methods": { "type": "array", "description": "How this seller delivers reporting data to buyers. Polling via get_media_buy_delivery is always available as a baseline regardless of this field. This array declares additional push-based delivery methods the seller supports. 'webhook': seller pushes to buyer-provided URL (configured per buy via reporting_webhook). 'offline': seller pushes batch files to a cloud storage bucket (seller-provisioned per account via reporting_bucket on the account object). When absent, only polling is available.", @@ -665,7 +693,7 @@ }, "supports_proposals": { "type": "boolean", - "description": "Conformance declaration that this seller supports the full proposal lifecycle on get_products: returned proposals are actionable, draft proposals can be finalized with buying_mode: 'refine' + action: 'finalize', and committed proposals can be executed via create_media_buy with proposal_id before expires_at. Buyers SHOULD NOT use this field to decide whether a specific returned proposal is executable; proposal_status is the per-proposal source of truth. A declaration of true opts the seller into proposal-lifecycle grading. When false or absent, conformance runners skip proposal-lifecycle storyboards, but buyers should still honor any proposals the seller actually returns.", + "description": "Conformance declaration that this seller supports actionable proposals through either the compact request/refine/finalize lifecycle or the legacy get_products facade. proposal_status is the per-proposal source of truth, and create_media_buy consumes the committed proposal_id before expires_at.", "default": false }, "governance_aware": { diff --git a/tests/composed-schema-validation.test.cjs b/tests/composed-schema-validation.test.cjs index 0397e2e476..5515405861 100644 --- a/tests/composed-schema-validation.test.cjs +++ b/tests/composed-schema-validation.test.cjs @@ -3033,44 +3033,54 @@ async function runTests() { url: 'https://buyer.example.com/adcp-events' } }, - 'list_products accepts wrapper callback configuration even though the read does not emit callbacks' + 'list_products tolerates uniform callback envelope configuration even though the read is synchronous' ); await testSchemaRejection( '/schemas/media-buy/list-products-request.json', - { catalog: { type: 'product' } }, + { criteria: { catalog: { catalog_id: 'catalog-1', type: 'product' } } }, 'list_products requires brand when catalog is present' ); await testSchemaValidation( - '/schemas/media-buy/recommend-products-request.json', + '/schemas/media-buy/request-proposals-request.json', { - idempotency_key: 'recommend-products-0001', + idempotency_key: 'request-proposals-0001', + brand: { domain: 'acmeoutdoor.example' }, brief: 'Reach streaming audio listeners in Rome' }, - 'recommend_products requires a brief and accepts a replay key' + 'request_proposals requires a brief and accepts a replay key' ); await testSchemaRejection( - '/schemas/media-buy/recommend-products-request.json', + '/schemas/media-buy/request-proposals-request.json', { brief: 'Reach streaming audio listeners in Rome' }, - 'recommend_products rejects a missing replay key' + 'request_proposals rejects a missing replay key' + ); + await testSchemaRejection( + '/schemas/media-buy/request-proposals-request.json', + { + idempotency_key: 'request-proposals-0002', + account_id: 'seller-account-1', + brief: 'Reach streaming audio listeners in Rome' + }, + 'request_proposals requires a stable BrandKey even when account_id is supplied' ); await testSchemaValidation( - '/schemas/media-buy/refine-proposal-request.json', + '/schemas/media-buy/refine-proposals-request.json', { - idempotency_key: 'refine-proposal-0001', - refine: [ - { scope: 'request', ask: 'Prefer video' }, - { scope: 'proposal', proposal_id: 'proposal-1', ask: 'Move budget to video' } + idempotency_key: 'refine-proposals-0001', + refinements: [ + { proposal_id: 'proposal-1', instructions: 'Prefer video and move budget toward it' }, + { proposal_id: 'proposal-2', instructions: 'Use only the premium video product' } ] }, - 'refine_proposal preserves non-finalizing refinement grammar' + 'refine_proposals accepts plural proposal-scoped immutable refinements' ); await testSchemaRejection( - '/schemas/media-buy/refine-proposal-request.json', + '/schemas/media-buy/refine-proposals-request.json', { - idempotency_key: 'refine-proposal-0002', - refine: [{ scope: 'proposal', proposal_id: 'proposal-1', action: 'finalize' }] + idempotency_key: 'refine-proposals-0002', + refinements: [{ proposal_id: 'proposal-1', action: 'finalize' }] }, - 'refine_proposal rejects finalization' + 'refine_proposals rejects finalization' ); await testSchemaValidation( '/schemas/media-buy/finalize-proposals-request.json', @@ -3088,6 +3098,121 @@ async function runTests() { }, 'finalize_proposals rejects duplicate proposal IDs' ); + const splitCapabilityBase = { + status: 'completed', + adcp: { + major_versions: [3], + idempotency: { supported: true, replay_ttl_seconds: 86400 } + }, + supported_protocols: ['media_buy'] + }; + await testSchemaValidation( + '/schemas/protocol/get-adcp-capabilities-response.json', + { + ...splitCapabilityBase, + media_buy: { + product_discovery_tools: ['request_proposals', 'finalize_proposals'], + max_atomic_finalize_batch_size: 25 + } + }, + 'finalize_proposals capability declares its atomic batch bound' + ); + await testSchemaRejection( + '/schemas/protocol/get-adcp-capabilities-response.json', + { + ...splitCapabilityBase, + media_buy: { product_discovery_tools: ['finalize_proposals'] } + }, + 'finalize_proposals capability rejects a missing atomic batch bound' + ); + await testSchemaRejection( + '/schemas/protocol/get-adcp-capabilities-response.json', + { + ...splitCapabilityBase, + media_buy: { + product_discovery_tools: ['finalize_proposals'], + max_atomic_finalize_batch_size: 26 + } + }, + 'finalize_proposals capability rejects a bound above the protocol ceiling' + ); + await testSchemaValidation( + '/schemas/media-buy/list-products-response.json', + { products: [] }, + 'list_products treats no matches as an empty successful product page' + ); + await testSchemaValidation( + '/schemas/media-buy/request-proposals-response.json', + { + outcome: 'rejected', + reason: 'No available offer satisfies the campaign constraints.', + suggestions: ['Broaden the flight dates.'] + }, + 'request_proposals has an explicit business-rejection outcome' + ); + await testSchemaRejection( + '/schemas/media-buy/request-proposals-response.json', + { products: [] }, + 'request_proposals cannot return products without a proposal' + ); + await testSchemaRejection( + '/schemas/media-buy/request-proposals-response.json', + { + proposals: [{ + proposal_id: 'proposal-1', + name: 'Draft premium video plan', + allocations: [{ product_id: 'premium-video', allocation_percentage: 100 }], + proposal_status: 'draft', + expires_at: '2027-06-30T23:59:59Z' + }], + products: [{ ...productBase, product_id: 'premium-video' }], + reason: 'This must not appear on the success arm.' + }, + 'request_proposals success cannot carry rejection fields' + ); + await testSchemaRejection( + '/schemas/media-buy/refine-proposals-response.json', + { + results: [{ + source_proposal_id: 'proposal-1', + outcome: 'partial', + proposal: { + proposal_id: 'proposal-2', + name: 'Partially revised premium video plan', + allocations: [{ product_id: 'premium-video', allocation_percentage: 100 }], + proposal_status: 'draft', + expires_at: '2027-06-30T23:59:59Z' + } + }], + products: [] + }, + 'refine_proposals partial results require explanatory notes' + ); + await testSchemaValidation( + '/schemas/media-buy/finalize-proposals-response.json', + { + proposals: [{ + proposal_id: 'proposal-1', + name: 'Committed premium video plan', + allocations: [{ product_id: 'premium-video', allocation_percentage: 100 }], + proposal_status: 'committed', + expires_at: '2027-06-30T23:59:59Z' + }] + }, + 'finalize_proposals returns committed proposals with hold expiry' + ); + await testSchemaRejection( + '/schemas/media-buy/finalize-proposals-response.json', + { + proposals: [{ + proposal_id: 'proposal-1', + name: 'Uncommitted premium video plan', + allocations: [{ product_id: 'premium-video', allocation_percentage: 100 }], + proposal_status: 'draft' + }] + }, + 'finalize_proposals rejects draft proposal results' + ); log(''); diff --git a/tests/docs-nav-validation.test.cjs b/tests/docs-nav-validation.test.cjs index 74871396aa..464c27017b 100644 --- a/tests/docs-nav-validation.test.cjs +++ b/tests/docs-nav-validation.test.cjs @@ -248,8 +248,8 @@ test('temporary snapshot redirects cover every available live page', () => { 'docs/protocol/language-and-localization', 'docs/protocol/sync_agent_notification_configs', 'docs/media-buy/task-reference/list_products', - 'docs/media-buy/task-reference/recommend_products', - 'docs/media-buy/task-reference/refine_proposal', + 'docs/media-buy/task-reference/request_proposals', + 'docs/media-buy/task-reference/refine_proposals', 'docs/media-buy/task-reference/finalize_proposals', 'docs/creative/channels/radio', 'docs/brand-protocol/tasks/search_brands', diff --git a/tests/schema-validation.test.cjs b/tests/schema-validation.test.cjs index 48b6d737c0..6847871ea4 100644 --- a/tests/schema-validation.test.cjs +++ b/tests/schema-validation.test.cjs @@ -24,7 +24,7 @@ addFormats(ajv); // Schema loader for resolving $ref async function loadExternalSchema(uri) { if (uri.startsWith('/schemas/')) { - const schemaPath = path.join(SCHEMA_BASE_DIR, uri.replace('/schemas/', '')); + const schemaPath = path.join(SCHEMA_BASE_DIR, uri.replace('/schemas/', '').split('#', 1)[0]); try { const content = fs.readFileSync(schemaPath, 'utf8'); return JSON.parse(content); @@ -146,7 +146,7 @@ function validateSchemaStructure(schemaPath, schema) { } function validateCrossReferences(schemas) { - const schemaIds = new Set(schemas.map(([_, schema]) => schema.$id)); + const schemasById = new Map(schemas.map(([_, schema]) => [schema.$id, schema])); const missingRefs = []; for (const [schemaPath, schema] of schemas) { @@ -166,9 +166,27 @@ function validateCrossReferences(schemas) { continue; } - // Check if referenced schema exists - if (!schemaIds.has(ref)) { + const hashIndex = ref.indexOf('#'); + const schemaId = hashIndex === -1 ? ref : ref.slice(0, hashIndex); + const fragment = hashIndex === -1 ? '' : ref.slice(hashIndex + 1); + const referencedSchema = schemasById.get(schemaId); + if (!referencedSchema) { missingRefs.push({ schema: schemaPath, ref }); + continue; + } + if (fragment) { + if (!fragment.startsWith('/')) { + missingRefs.push({ schema: schemaPath, ref }); + continue; + } + const resolved = fragment + .slice(1) + .split('/') + .map(segment => segment.replace(/~1/g, '/').replace(/~0/g, '~')) + .reduce((current, segment) => ( + current && typeof current === 'object' ? current[segment] : undefined + ), referencedSchema); + if (resolved === undefined) missingRefs.push({ schema: schemaPath, ref }); } } } From 99bfd48cb6cad913398cfad33f55e1547316350c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 11 Aug 2026 00:44:20 +0200 Subject: [PATCH 17/49] fix(media-buy): close split lifecycle contract gaps --- package-lock.json | 2 +- package.json | 2 +- server/src/training-agent/source-schema.ts | 4 +- server/src/training-agent/task-handlers.ts | 6 +- server/src/training-agent/webhooks.ts | 42 ++++- .../training-agent-webhooks.test.ts | 143 ++++++++++++++++-- .../product-discovery-schema-parity.test.ts | 10 ++ .../training-agent-webhook-contract.test.ts | 6 + server/tests/unit/training-agent.test.ts | 56 +++++++ static/schemas/source/enums/task-type.json | 6 + 10 files changed, 253 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index bee6bb90c9..1b2152e365 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@workos-inc/node": "^10.9.0", "@workos-inc/widgets": "^1.15.0", "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "axios": "^1.19.0", "canonicalize": "3.0.0", "cookie-parser": "^1.4.7", @@ -77,7 +78,6 @@ "@types/ws": "^8.18.1", "@types/yauzl": "^3.4.0", "@typescript/native": "npm:typescript@~7.0.2", - "ajv-formats": "^3.0.1", "c8": "^11.0.0", "concurrently": "^10.0.4", "glob": "^13.0.6", diff --git a/package.json b/package.json index 86bc85c08e..26181a4bd5 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,7 @@ "@workos-inc/node": "^10.9.0", "@workos-inc/widgets": "^1.15.0", "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "axios": "^1.19.0", "canonicalize": "3.0.0", "cookie-parser": "^1.4.7", @@ -216,7 +217,6 @@ "@types/ws": "^8.18.1", "@types/yauzl": "^3.4.0", "@typescript/native": "npm:typescript@~7.0.2", - "ajv-formats": "^3.0.1", "c8": "^11.0.0", "concurrently": "^10.0.4", "glob": "^13.0.6", diff --git a/server/src/training-agent/source-schema.ts b/server/src/training-agent/source-schema.ts index f4d80c914a..8fe36bae78 100644 --- a/server/src/training-agent/source-schema.ts +++ b/server/src/training-agent/source-schema.ts @@ -1,6 +1,7 @@ import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import Ajv, { type ErrorObject, type ValidateFunction } from 'ajv'; +import addFormats from 'ajv-formats'; type JsonSchema = Record; @@ -183,7 +184,8 @@ function productDiscoverySourceValidator(fileName: string): ValidateFunction { if (cached) return cached; if (!sourceAjv) { - sourceAjv = new Ajv({ strict: false, validateFormats: false }); + sourceAjv = new Ajv({ strict: false }); + addFormats(sourceAjv); for (const path of schemaFiles(schemaRoot)) { const schema = JSON.parse(readFileSync(path, 'utf8')) as JsonSchema; if (typeof schema.$id === 'string') sourceAjv.addSchema(schema, schema.$id); diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 8315da7ab2..797b2295c4 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -4416,7 +4416,7 @@ export function projectProductDiscoveryResult( source_proposal_id: sourceProposalId, outcome, proposal, - ...(typeof internalProposal?.__refinement_notes === 'string' + ...(outcome === 'partial' && typeof internalProposal?.__refinement_notes === 'string' && { notes: internalProposal.__refinement_notes }), } : { @@ -5673,7 +5673,7 @@ async function handleGetProductsUnlocked( const status = proposalLifecycle(proposal).proposal_status; if (status === 'committed') { refinementApplied.push({ scope: 'proposal', proposal_id: op.proposal_id, status: 'applied', notes: 'Proposal already committed' }); - } else if (status === 'draft') { + } else { const committed = { ...proposal } as Record & ProposalLifecycle; committed.proposal_status = 'committed'; (committed as Record).expires_at = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); @@ -5710,8 +5710,6 @@ async function handleGetProductsUnlocked( stagedProposalCommits.set(op.proposal_id, updatedProposal); refinementApplied.push({ scope: 'proposal', proposal_id: op.proposal_id, status: 'applied', notes: 'Proposal finalized — pricing committed, inventory held for 24 hours' }); - } else { - refinementApplied.push({ scope: 'proposal', proposal_id: op.proposal_id, status: 'applied', notes: 'Proposal is already ready to buy (no finalization needed)' }); } } } else if (op.scope === 'request') { diff --git a/server/src/training-agent/webhooks.ts b/server/src/training-agent/webhooks.ts index e1a0fdfa2f..498ed66dbb 100644 --- a/server/src/training-agent/webhooks.ts +++ b/server/src/training-agent/webhooks.ts @@ -37,7 +37,8 @@ const logger = createLogger('training-agent-webhooks'); * Keep in sync with `static/schemas/source/core/mcp-webhook-payload.json`. */ export type WebhookTaskType = | 'create_media_buy' | 'update_media_buy' | 'sync_creatives' | 'build_creative' - | 'get_products' | 'activate_signal' + | 'get_products' | 'request_proposals' | 'refine_proposals' | 'finalize_proposals' + | 'activate_signal' | 'get_signals' | 'create_property_list' | 'update_property_list' | 'get_property_list' | 'list_property_lists' | 'delete_property_list' | 'sync_accounts' | 'get_account_financials' | 'get_creative_delivery' | 'sync_event_sources' @@ -46,6 +47,9 @@ export type WebhookTaskType = export const TOOL_TO_TASK_TYPE = { get_products: 'get_products', + request_proposals: 'request_proposals', + refine_proposals: 'refine_proposals', + finalize_proposals: 'finalize_proposals', create_media_buy: 'create_media_buy', update_media_buy: 'update_media_buy', sync_creatives: 'sync_creatives', @@ -83,6 +87,9 @@ type WebhookProtocol = 'media-buy' | 'signals' | 'governance' | 'creative' | 'br export const TOOL_TO_PROTOCOL: Readonly> = { get_products: 'media-buy', + request_proposals: 'media-buy', + refine_proposals: 'media-buy', + finalize_proposals: 'media-buy', create_media_buy: 'media-buy', update_media_buy: 'media-buy', sync_creatives: 'media-buy', @@ -124,6 +131,29 @@ function extractBuyerOperationId(args: Record): string | undefi return typeof pnc.operation_id === 'string' && pnc.operation_id.length > 0 ? pnc.operation_id : undefined; } +function extractWebhookToken(args: Record): string | undefined { + const pnc = args.push_notification_config as { token?: unknown } | undefined; + if (!pnc || typeof pnc !== 'object') return undefined; + return typeof pnc.token === 'string' && pnc.token.length > 0 ? pnc.token : undefined; +} + +function extractWebhookAuthentication(args: Record): WebhookAuthentication | undefined { + const pnc = args.push_notification_config as { + authentication?: { schemes?: unknown; credentials?: unknown }; + } | undefined; + const authentication = pnc?.authentication; + if (!authentication || typeof authentication !== 'object' + || !Array.isArray(authentication.schemes) + || typeof authentication.credentials !== 'string') return undefined; + if (authentication.schemes[0] === 'Bearer') { + return { type: 'bearer', token: authentication.credentials }; + } + if (authentication.schemes[0] === 'HMAC-SHA256') { + return { type: 'hmac_sha256', secret: authentication.credentials }; + } + return undefined; +} + /** Derive a stable scope key for the **webhook idempotency-key store** — * NOT the wire-level `operation_id`. Two emissions with the same scope key * reuse the same `idempotency_key` across retries. Prefers a buyer-facing @@ -202,6 +232,7 @@ export function maybeEmitCompletionWebhook(opts: { // the principal-scoped `idempotencyScope` on the wire: it embeds the // seller-side auth token. const wireOperationId = extractBuyerOperationId(opts.args) ?? webhookTaskId; + const token = extractWebhookToken(opts.args); const payload: Record = { operation_id: wireOperationId, task_id: webhookTaskId, @@ -209,9 +240,16 @@ export function maybeEmitCompletionWebhook(opts: { protocol: TOOL_TO_PROTOCOL[tool], status: 'completed', timestamp: new Date().toISOString(), + ...(token !== undefined && { token }), result: opts.response, }; - void emitter.emit({ url: webhookUrl, payload, operation_id: idempotencyScope }) + const authentication = extractWebhookAuthentication(opts.args); + void emitter.emit({ + url: webhookUrl, + payload, + operation_id: idempotencyScope, + ...(authentication !== undefined && { authentication }), + }) .catch(err => logger.warn({ err, tool: opts.toolName, url: webhookUrl }, 'Webhook emission failed')); } diff --git a/server/tests/integration/training-agent-webhooks.test.ts b/server/tests/integration/training-agent-webhooks.test.ts index 6143b53a2f..c5b897997a 100644 --- a/server/tests/integration/training-agent-webhooks.test.ts +++ b/server/tests/integration/training-agent-webhooks.test.ts @@ -31,7 +31,12 @@ vi.mock('../../src/logger.js', () => ({ const { createTrainingAgentRouter } = await import('../../src/training-agent/index.js'); const { stopSessionCleanup, clearSessions, getSession, runWithSessionContext, sessionKeyFromArgs } = await import('../../src/training-agent/state.js'); const { clearAccountStore } = await import('../../src/training-agent/account-handlers.js'); -const { resetWebhookSigning, getPublicJwks, emitFrameworkTaskWebhook } = await import('../../src/training-agent/webhooks.js'); +const { + resetWebhookSigning, + getPublicJwks, + emitFrameworkTaskWebhook, + maybeEmitCompletionWebhook, +} = await import('../../src/training-agent/webhooks.js'); const { handleCreatePropertyList, handleUpdatePropertyList } = await import('../../src/training-agent/property-handlers.js'); const AUTH = 'Bearer test-token-webhook'; @@ -180,21 +185,21 @@ describe('Training Agent webhook emission', () => { } }, 20000); - it('uses MCP task replay without a tool-specific webhook for request_proposals', async () => { + it('emits token-correlated callbacks across the split proposal lifecycle', async () => { const deliveries: CapturedDelivery[] = []; let srv: http.Server | undefined; try { + let resolveDeliveries: (() => void) | undefined; + const delivered = new Promise(resolve => { resolveDeliveries = resolve; }); srv = await startReceiver((delivery, res) => { deliveries.push(delivery); res.writeHead(200); res.end(); + if (deliveries.length === 3) resolveDeliveries?.(); }); - const logicalRequest = { - idempotency_key: `split-products-${randomUUID()}`, - brand: { domain: 'split-webhook.example' }, - brief: 'Reach sports fans', - }; - const call = () => request(app) + const addr = srv.address() as AddressInfo; + const webhookUrl = `http://127.0.0.1:${addr.port}/hook/split-proposals`; + const call = (name: string, args: Record) => request(app) .post('/api/training-agent/sales/mcp') .set('Authorization', AUTH) .set('Content-Type', 'application/json') @@ -203,16 +208,124 @@ describe('Training Agent webhook emission', () => { jsonrpc: '2.0', id: randomUUID(), method: 'tools/call', - params: { name: 'request_proposals', arguments: logicalRequest }, + params: { name, arguments: args }, }); - const first = await call(); - const replay = await call(); - expect(structuredToolResult(first)).not.toHaveProperty('adcp_error'); - expect(structuredToolResult(replay)).toMatchObject({ replayed: true }); - await new Promise(resolve => setTimeout(resolve, 100)); + const callback = (operationId: string) => ({ + url: webhookUrl, + operation_id: operationId, + token: 'split-callback-token-1234', + }); + const requestedResponse = await call('request_proposals', { + idempotency_key: `split-request-${randomUUID()}`, + brand: { domain: 'split-webhook.example' }, + brief: 'Reach sports fans with social display', + push_notification_config: callback('op_request_proposals'), + }); + const requested = structuredToolResult(requestedResponse); + expect(requested).not.toHaveProperty('adcp_error'); + const source = (requested.proposals as Array>)[0]; + + const refinedResponse = await call('refine_proposals', { + idempotency_key: `split-refine-${randomUUID()}`, + refinements: [{ + proposal_id: source.proposal_id, + instructions: 'Prefer social inventory while preserving the total budget.', + }], + push_notification_config: callback('op_refine_proposals'), + }); + const refined = structuredToolResult(refinedResponse); + expect(refined).not.toHaveProperty('adcp_error'); + const revision = ((refined.results as Array>)[0].proposal) as Record; + + const finalizedResponse = await call('finalize_proposals', { + idempotency_key: `split-finalize-${randomUUID()}`, + proposal_ids: [revision.proposal_id], + push_notification_config: callback('op_finalize_proposals'), + }); + expect(structuredToolResult(finalizedResponse)).not.toHaveProperty('adcp_error'); + await Promise.race([ + delivered, + new Promise((_, reject) => setTimeout(() => reject(new Error('split lifecycle webhooks never arrived')), 10_000)), + ]); + + expect(deliveries).toHaveLength(3); + const bodies = deliveries.map(delivery => JSON.parse(delivery.body) as Record); + expect(bodies.map(body => body.task_type)).toEqual([ + 'request_proposals', + 'refine_proposals', + 'finalize_proposals', + ]); + expect(bodies.map(body => body.operation_id)).toEqual([ + 'op_request_proposals', + 'op_refine_proposals', + 'op_finalize_proposals', + ]); + expect(bodies.every(body => body.token === 'split-callback-token-1234')).toBe(true); + } finally { + if (srv) { + srv.closeAllConnections?.(); + await new Promise(resolve => srv!.close(() => resolve())); + } + } + }, 20000); + + it('honors legacy callback authentication when emitting split-tool webhooks', async () => { + const deliveries: CapturedDelivery[] = []; + let srv: http.Server | undefined; + try { + let resolveDeliveries: (() => void) | undefined; + const delivered = new Promise(resolve => { resolveDeliveries = resolve; }); + srv = await startReceiver((delivery, res) => { + deliveries.push(delivery); + res.writeHead(200); + res.end(); + if (deliveries.length === 2) resolveDeliveries?.(); + }); + const addr = srv.address() as AddressInfo; + maybeEmitCompletionWebhook({ + toolName: 'request_proposals', + args: { + push_notification_config: { + url: `http://127.0.0.1:${addr.port}/hook/bearer`, + operation_id: 'op_split_bearer', + authentication: { + schemes: ['Bearer'], + credentials: 'legacy-bearer-credential-1234567890', + }, + }, + }, + response: { proposals: [], products: [] }, + principal: 'webhook-test-principal', + }); + maybeEmitCompletionWebhook({ + toolName: 'finalize_proposals', + args: { + push_notification_config: { + url: `http://127.0.0.1:${addr.port}/hook/hmac`, + operation_id: 'op_split_hmac', + authentication: { + schemes: ['HMAC-SHA256'], + credentials: 'legacy-hmac-credential-123456789012', + }, + }, + }, + response: { proposals: [] }, + principal: 'webhook-test-principal', + }); + await Promise.race([ + delivered, + new Promise((_, reject) => setTimeout(() => reject(new Error('authenticated webhook never arrived')), 10_000)), + ]); - expect(deliveries).toHaveLength(0); + expect(deliveries).toHaveLength(2); + const bearer = deliveries.find(delivery => delivery.url.endsWith('/hook/bearer'))!; + const hmac = deliveries.find(delivery => delivery.url.endsWith('/hook/hmac'))!; + expect(bearer.headers.authorization).toBe('Bearer legacy-bearer-credential-1234567890'); + expect(bearer.headers['signature-input']).toBeUndefined(); + expect(hmac.headers['x-adcp-timestamp']).toMatch(/^\d+$/); + expect(hmac.headers['x-adcp-signature']).toMatch(/^sha256=[a-f0-9]{64}$/); + expect(hmac.headers['signature-input']).toBeUndefined(); } finally { if (srv) { srv.closeAllConnections?.(); diff --git a/server/tests/unit/product-discovery-schema-parity.test.ts b/server/tests/unit/product-discovery-schema-parity.test.ts index 94b7e2c611..6e5bb7b2ca 100644 --- a/server/tests/unit/product-discovery-schema-parity.test.ts +++ b/server/tests/unit/product-discovery-schema-parity.test.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; import Ajv from 'ajv'; import { describe, expect, it } from 'vitest'; import { productDiscoveryAliasToolDefinitions } from '../../src/training-agent/task-handlers.js'; +import { validateProductDiscoverySourceInput } from '../../src/training-agent/source-schema.js'; type JsonSchema = Record; @@ -103,4 +104,13 @@ describe('product discovery MCP schema parity', () => { expect(criteria.properties).not.toHaveProperty('targeting_overlay'); expect(criteria.properties).not.toHaveProperty('required_overlay_support'); }); + + it('enforces URI formats from the normative source schema at dispatch', () => { + expect(validateProductDiscoverySourceInput('request-proposals-request', { + idempotency_key: 'format-check-key-1234', + brand: { domain: 'format-check.example' }, + brief: 'Reach relevant buyers', + push_notification_config: { url: 'not a uri' }, + })).toMatchObject({ field: 'push_notification_config.url' }); + }); }); diff --git a/server/tests/unit/training-agent-webhook-contract.test.ts b/server/tests/unit/training-agent-webhook-contract.test.ts index 82f1c91533..65858fa643 100644 --- a/server/tests/unit/training-agent-webhook-contract.test.ts +++ b/server/tests/unit/training-agent-webhook-contract.test.ts @@ -18,11 +18,17 @@ describe('training-agent completion webhook contract', () => { expect(Object.values(TOOL_TO_TASK_TYPE).every(taskType => schema.enum.includes(taskType))).toBe(true); expect(TOOL_TO_TASK_TYPE).toMatchObject({ get_products: 'get_products', + request_proposals: 'request_proposals', + refine_proposals: 'refine_proposals', + finalize_proposals: 'finalize_proposals', build_creative: 'build_creative', update_rights: 'update_rights', }); expect(TOOL_TO_PROTOCOL).toMatchObject({ get_products: 'media-buy', + request_proposals: 'media-buy', + refine_proposals: 'media-buy', + finalize_proposals: 'media-buy', build_creative: 'creative', update_rights: 'brand', }); diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index 8e0459b67d..33a22bd22a 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -12624,6 +12624,62 @@ describe('proposal lifecycle', () => { expect(purchased.media_buy_id).toEqual(expect.any(String)); }); + it('omits partial-only notes from a fully revised proposal result', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const { result: requested, isError: requestError } = await simulateCallTool(server, 'request_proposals', { + brand: account.brand, + brief: 'social engagement display', + }); + expect(requestError).toBeFalsy(); + const source = (requested.proposals as Array>)[0]; + + const { result: refined, isError: refineError } = await simulateCallTool(server, 'refine_proposals', { + refinements: [{ + proposal_id: source.proposal_id, + instructions: 'Provide concrete fixed CPM pricing in USD.', + }], + }); + expect(refineError).toBeFalsy(); + const revision = (refined.results as Array>)[0]; + expect(revision).toMatchObject({ + source_proposal_id: source.proposal_id, + outcome: 'revised', + proposal: { proposal_status: 'draft' }, + }); + expect(revision).not.toHaveProperty('notes'); + }); + + it('commits a statusless backward-compatible proposal before returning it from finalize_proposals', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const { result: requested, isError: requestError } = await simulateCallTool(server, 'request_proposals', { + brand: account.brand, + brief: 'social engagement display', + }); + expect(requestError).toBeFalsy(); + const source = (requested.proposals as Array>)[0]; + const compactSessionKey = sessionKeyFromArgs({}, DEFAULT_CTX.mode, undefined, undefined, 'anonymous'); + await runWithSessionContext(async () => { + const session = await getSession(compactSessionKey); + const stored = session.lastGetProductsContext?.proposals?.find( + proposal => proposal.proposal_id === source.proposal_id, + ) as (Record | undefined); + expect(stored).toBeDefined(); + delete stored!.proposal_status; + delete stored!.expires_at; + await flushDirtySessions(); + }); + + const { result: finalized, isError: finalizeError } = await simulateCallTool(server, 'finalize_proposals', { + proposal_ids: [source.proposal_id], + }); + expect(finalizeError).toBeFalsy(); + expect((finalized.proposals as Array>)[0]).toMatchObject({ + proposal_id: source.proposal_id, + proposal_status: 'committed', + expires_at: expect.any(String), + }); + }); + it('binds compact proposals to both the seller account and full BrandKey', async () => { const server = createTrainingAgentServer(DEFAULT_CTX); const originalBrand = { domain: 'proposal-house.example', brand_id: 'alpha' }; diff --git a/static/schemas/source/enums/task-type.json b/static/schemas/source/enums/task-type.json index 22aa0f6c2b..fbef22a2eb 100644 --- a/static/schemas/source/enums/task-type.json +++ b/static/schemas/source/enums/task-type.json @@ -13,6 +13,9 @@ "preview_creative", "activate_signal", "get_products", + "request_proposals", + "refine_proposals", + "finalize_proposals", "get_signals", "create_property_list", "update_property_list", @@ -42,6 +45,9 @@ "preview_creative": "Creative domain: Render a read-only preview of an existing creative manifest or library creative", "activate_signal": "Signals domain: Activate an audience signal on a specific platform or account", "get_products": "Media-buy domain: Discover or refine advertising products and proposals", + "request_proposals": "Media-buy domain: Request seller-authored proposals from a campaign brief", + "refine_proposals": "Media-buy domain: Create revised proposal drafts from existing proposals", + "finalize_proposals": "Media-buy domain: Atomically commit proposal drafts to firm terms", "get_signals": "Signals domain: Discover available audience signals based on natural language description", "create_property_list": "Property domain: Create a new property list with filters and brand reference", "update_property_list": "Property domain: Update an existing property list", From 7581d976a3d0b0945476dedf006aea54b5288b60 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 11 Aug 2026 00:49:48 +0200 Subject: [PATCH 18/49] fix(media-buy): project split lifecycle webhooks --- server/src/training-agent/task-handlers.ts | 7 ++++++- .../integration/training-agent-webhooks.test.ts | 9 +++++++++ .../schemas/source/core/async-response-data.json | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 797b2295c4..a3d67a5e97 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -12085,10 +12085,15 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { || toolResult!.isError || handlerThrew ) return; + const webhookResponse = projectProductDiscoveryResult( + name, + cachableResponse, + initialHandlerArgs, + ); maybeEmitCompletionWebhook({ toolName: name, args: initialHandlerArgs, - response: cachableResponse, + response: webhookResponse, requestIdempotencyKey: typeof idempotencyKey === 'string' ? idempotencyKey : undefined, principal: idempotencyPrincipal, }); diff --git a/server/tests/integration/training-agent-webhooks.test.ts b/server/tests/integration/training-agent-webhooks.test.ts index c5b897997a..f39ea85f6f 100644 --- a/server/tests/integration/training-agent-webhooks.test.ts +++ b/server/tests/integration/training-agent-webhooks.test.ts @@ -262,6 +262,15 @@ describe('Training Agent webhook emission', () => { 'op_finalize_proposals', ]); expect(bodies.every(body => body.token === 'split-callback-token-1234')).toBe(true); + const results = bodies.map(body => body.result as Record); + expect(results[0]).toMatchObject({ proposals: expect.any(Array), products: expect.any(Array) }); + expect(results[0]).not.toHaveProperty('refinement_applied'); + expect(results[1]).toMatchObject({ results: expect.any(Array), products: expect.any(Array) }); + expect(results[1]).not.toHaveProperty('proposals'); + expect(results[1]).not.toHaveProperty('refinement_applied'); + expect(results[2]).toMatchObject({ proposals: expect.any(Array) }); + expect(results[2]).not.toHaveProperty('products'); + expect(results[2]).not.toHaveProperty('refinement_applied'); } finally { if (srv) { srv.closeAllConnections?.(); diff --git a/static/schemas/source/core/async-response-data.json b/static/schemas/source/core/async-response-data.json index 4819a97cdb..1d8e13e95e 100644 --- a/static/schemas/source/core/async-response-data.json +++ b/static/schemas/source/core/async-response-data.json @@ -29,6 +29,21 @@ "description": "Acknowledgment for submitted get_products (custom curation)", "$ref": "/schemas/media-buy/get-products-async-response-submitted.json" }, + { + "title": "RequestProposalsResponse", + "description": "Terminal response for request_proposals", + "$ref": "/schemas/media-buy/request-proposals-response.json" + }, + { + "title": "RefineProposalsResponse", + "description": "Terminal response for refine_proposals", + "$ref": "/schemas/media-buy/refine-proposals-response.json" + }, + { + "title": "FinalizeProposalsResponse", + "description": "Terminal response for finalize_proposals", + "$ref": "/schemas/media-buy/finalize-proposals-response.json" + }, { "title": "GetSignalsResponse", "description": "Response for completed or failed get_signals", From 0b61d497fbfd790a04d23c2b5b348b4d00ad3551 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 11 Aug 2026 01:30:56 +0200 Subject: [PATCH 19/49] fix(training): preserve storyboard fixture isolation --- .../training-agent/comply-test-controller.ts | 24 ++- server/src/training-agent/task-handlers.ts | 16 +- server/tests/unit/training-agent.test.ts | 163 ++++++++++++++++++ 3 files changed, 199 insertions(+), 4 deletions(-) diff --git a/server/src/training-agent/comply-test-controller.ts b/server/src/training-agent/comply-test-controller.ts index e3cbd5ee73..498b3a1c4e 100644 --- a/server/src/training-agent/comply-test-controller.ts +++ b/server/src/training-agent/comply-test-controller.ts @@ -1234,6 +1234,26 @@ export async function handleComplyTestController(args: ToolArgs, ctx: TrainingCo const opaqueAccountId = typeof args.account?.account_id === 'string' ? args.account.account_id : undefined; + let staticFixtureAccount: ToolArgs['account'] | undefined; + if (targetsControllerFixtureState && ctx.principal?.startsWith('static:') && args.account) { + try { + const canonical = canonicalizeAccountRef(args.account); + if (canonical.kind === 'natural' && canonical.sandbox) { + // Public/demo credentials share one non-production fixture sandbox. + // The SDK controller seeds with operator=brand.domain, while authored + // task examples may name the buyer operator. Canonicalize only this + // fixture projection to the brand-owned sandbox partition; real + // principals keep the complete natural account identity. + staticFixtureAccount = { + brand: canonical.brand, + operator: canonical.brand.domain, + sandbox: true, + }; + } + } catch { + staticFixtureAccount = undefined; + } + } const sessionArgs = legacyNaturalBrandDomain ? { ...args, account: undefined, brand: { domain: legacyNaturalBrandDomain } } : opaqueAccountId @@ -1244,7 +1264,9 @@ export async function handleComplyTestController(args: ToolArgs, ctx: TrainingCo // same `a:` partition. A top-level storyboard brand must not // override that opaque identity. ? { ...args, account: { account_id: opaqueAccountId }, brand: undefined } - : args; + : staticFixtureAccount + ? { ...args, account: staticFixtureAccount } + : args; let sessionKey = targetsGetProductsState ? getProductsSessionKeyFromArgs(sessionArgs, ctx.mode, ctx.userId, ctx.moduleId) : sessionKeyFromArgs( diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 8506866642..8a497e534a 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -2381,12 +2381,22 @@ function controllerFixtureSessionKey( if (!account.sandbox && ctx.principal && !ctx.principal.startsWith('static:')) return undefined; fixtureAccount = { brand: account.brand, - operator: account.operator, + // SDK controller seeding uses the brand domain as operator. Public + // static credentials intentionally share one demo fixture sandbox, + // so task examples using a buyer operator resolve the same brand-owned + // fixtures. Authenticated principals retain full operator isolation. + operator: ctx.principal?.startsWith('static:') + ? account.brand.domain + : account.operator, sandbox: true, }; } else { - fixtureAccount = sandboxAccountRefForId(account.account_id, ctx.principal); - if (!fixtureAccount) return undefined; + // Resolve first so an opaque ID is usable only by the principal that + // owns that sandbox account. Keep the opaque identity for projection: + // controller fixture writes are keyed by account_id, and resolving it + // to a natural ref here would fork reads into a different partition. + if (!sandboxAccountRefForId(account.account_id, ctx.principal)) return undefined; + fixtureAccount = { account_id: account.account_id }; } } catch { return undefined; diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index 282b68a1f6..abf670a5a6 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -2679,6 +2679,169 @@ describe('validate_input handler', () => { ]); }); + it('projects static storyboard fixtures across buyer operators', async () => { + const server = createTrainingAgentServer({ mode: 'open', principal: 'static:demo:storyboard' }); + const brand = { domain: 'static-fixture-projection.example' }; + const controllerAccount = { brand, operator: brand.domain }; + const buyerAccount = { brand, operator: 'pinnacle-agency.example' }; + await simulateCallTool(server, 'comply_test_controller', { + account: controllerAccount, + brand, + scenario: 'seed_product', + params: { + product_id: 'static_fixture_projection_product', + fixture: { + channels: ['olv'], + delivery_type: 'guaranteed', + format_options: [{ + format_kind: 'video_hosted', + format_option_id: 'static_fixture_projection_brief', + params: { + synthesis_nondeterministic: true, + slots: [{ asset_group_id: 'creative_brief', asset_type: 'brief', required: true }], + }, + }], + }, + }, + }); + + const { result } = await simulateCallTool(server, 'validate_input', { + account: buyerAccount, + manifest: { + format_kind: 'video_hosted', + format_option_ref: { scope: 'product', format_option_id: 'static_fixture_projection_brief' }, + assets: {}, + }, + targets: [{ kind: 'product', id: 'static_fixture_projection_product' }], + }); + + expect(result.results).toEqual([ + expect.objectContaining({ + result_kind: 'validated_fail', + violations: [expect.objectContaining({ rule: 'required_slot', field: 'assets.creative_brief' })], + }), + ]); + }); + + it('keeps projected fixtures isolated between real same-brand operators', async () => { + const server = createTrainingAgentServer({ mode: 'open', principal: 'workos:fixture-operator-isolation' }); + const brand = { domain: 'fixture-operator-isolation.example' }; + const ownerAccount = { brand, operator: 'owner-agency.example', sandbox: true }; + const otherAccount = { brand, operator: 'other-agency.example', sandbox: true }; + const productId = 'operator_isolated_fixture_product'; + const formatOptionId = 'operator_isolated_fixture_brief'; + await simulateCallTool(server, 'comply_test_controller', { + account: ownerAccount, + brand, + scenario: 'seed_product', + params: { + product_id: productId, + fixture: { + channels: ['olv'], + delivery_type: 'guaranteed', + format_options: [{ + format_kind: 'video_hosted', + format_option_id: formatOptionId, + params: { + slots: [{ asset_group_id: 'creative_brief', asset_type: 'brief', required: true }], + }, + }], + }, + }, + }); + + const validateFor = (account: typeof ownerAccount) => simulateCallTool(server, 'validate_input', { + account, + manifest: { + format_kind: 'video_hosted', + format_option_ref: { scope: 'product', format_option_id: formatOptionId }, + assets: {}, + }, + targets: [{ kind: 'product', id: productId }], + }); + const ownerResult = (await validateFor(ownerAccount)).result; + const otherResult = (await validateFor(otherAccount)).result; + + expect(ownerResult.results).toEqual([ + expect.objectContaining({ + violations: [expect.objectContaining({ rule: 'required_slot' })], + }), + ]); + expect(otherResult.results).toEqual([ + expect.objectContaining({ + violations: [expect.objectContaining({ rule: 'product_target_found' })], + }), + ]); + }); + + it('keeps projected fixtures isolated between opaque accounts', async () => { + const server = createTrainingAgentServer({ mode: 'open', principal: 'workos:fixture-account-isolation' }); + const ownerAccountId = 'acct_fixture_owner'; + const otherAccountId = 'acct_fixture_other'; + for (const [accountId, operator] of [ + [ownerAccountId, 'owner-agency.example'], + [otherAccountId, 'other-agency.example'], + ]) { + await simulateCallTool(server, 'comply_test_controller', { + account: { account_id: accountId }, + scenario: 'seed_account', + params: { + account_id: accountId, + fixture: { + brand: { domain: 'opaque-fixture-isolation.example' }, + operator, + billing: 'operator', + sandbox: true, + status: 'active', + }, + }, + }); + } + const productId = 'opaque_isolated_fixture_product'; + const formatOptionId = 'opaque_isolated_fixture_brief'; + await simulateCallTool(server, 'comply_test_controller', { + account: { account_id: ownerAccountId }, + scenario: 'seed_product', + params: { + product_id: productId, + fixture: { + channels: ['olv'], + delivery_type: 'guaranteed', + format_options: [{ + format_kind: 'video_hosted', + format_option_id: formatOptionId, + params: { + slots: [{ asset_group_id: 'creative_brief', asset_type: 'brief', required: true }], + }, + }], + }, + }, + }); + + const validateFor = (accountId: string) => simulateCallTool(server, 'validate_input', { + account: { account_id: accountId }, + manifest: { + format_kind: 'video_hosted', + format_option_ref: { scope: 'product', format_option_id: formatOptionId }, + assets: {}, + }, + targets: [{ kind: 'product', id: productId }], + }); + const ownerResult = (await validateFor(ownerAccountId)).result; + const otherResult = (await validateFor(otherAccountId)).result; + + expect(ownerResult.results).toEqual([ + expect.objectContaining({ + violations: [expect.objectContaining({ rule: 'required_slot' })], + }), + ]); + expect(otherResult.results).toEqual([ + expect.objectContaining({ + violations: [expect.objectContaining({ rule: 'product_target_found' })], + }), + ]); + }); + it('does not infer third-party validation from a legacy format_id manifest', async () => { const server = createTrainingAgentServer(DEFAULT_CTX); const { result } = await simulateCallTool(server, 'validate_input', { From 9d27ef93756b80b92822887e080f27a9fc2ef863 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 11 Aug 2026 01:53:23 +0200 Subject: [PATCH 20/49] test(media-buy): remove duplicate brand fixture --- server/tests/unit/training-agent-idempotency.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/tests/unit/training-agent-idempotency.test.ts b/server/tests/unit/training-agent-idempotency.test.ts index b41014085e..8fa6df67a8 100644 --- a/server/tests/unit/training-agent-idempotency.test.ts +++ b/server/tests/unit/training-agent-idempotency.test.ts @@ -462,7 +462,6 @@ describe('training agent idempotency middleware', () => { const identity = { adcp_version: '3.2-beta.0', brand: BRAND, - brand: BRAND, }; const first = await call(server, 'get_products', { From fad366a6764b61533c6006f994f922cb5305e530 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 11 Aug 2026 02:05:25 +0200 Subject: [PATCH 21/49] docs(media-buy): use stable schema aliases --- docs/media-buy/task-reference/finalize_proposals.mdx | 3 +-- docs/media-buy/task-reference/list_products.mdx | 3 +-- docs/media-buy/task-reference/refine_proposals.mdx | 3 +-- docs/media-buy/task-reference/request_proposals.mdx | 3 +-- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/docs/media-buy/task-reference/finalize_proposals.mdx b/docs/media-buy/task-reference/finalize_proposals.mdx index 7e5e0e4a50..84926947e6 100644 --- a/docs/media-buy/task-reference/finalize_proposals.mdx +++ b/docs/media-buy/task-reference/finalize_proposals.mdx @@ -7,8 +7,7 @@ testable: true `finalize_proposals` is the explicit proposal commit boundary. `idempotency_key` is required, and `proposal_ids` must be non-empty and unique. The request carries no brand, account, catalog, or targeting state: each opaque proposal ID is resolved under the authenticated principal and already binds that state. -{/* Using latest because this schema is not yet released. */} -**Request schema:** [`/schemas/latest/media-buy/finalize-proposals-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/finalize-proposals-request.json) +**Request schema:** [`/schemas/v3/media-buy/finalize-proposals-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/finalize-proposals-request.json) ```json { diff --git a/docs/media-buy/task-reference/list_products.mdx b/docs/media-buy/task-reference/list_products.mdx index e9fa5c2f6d..6209a95cc3 100644 --- a/docs/media-buy/task-reference/list_products.mdx +++ b/docs/media-buy/task-reference/list_products.mdx @@ -7,8 +7,7 @@ testable: true `list_products` is the side-effect-free entry point to the compact product lifecycle. It returns products only—never proposals—and always completes synchronously. Buyers can purchase a listed product directly or pass returned `product_id` values to [`request_proposals`](/docs/media-buy/task-reference/request_proposals) for seller planning. -{/* Using latest because this schema is not yet released. */} -**Request schema:** [`/schemas/latest/media-buy/list-products-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/list-products-request.json) +**Request schema:** [`/schemas/v3/media-buy/list-products-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/list-products-request.json) ```json { diff --git a/docs/media-buy/task-reference/refine_proposals.mdx b/docs/media-buy/task-reference/refine_proposals.mdx index c748319900..d242c202aa 100644 --- a/docs/media-buy/task-reference/refine_proposals.mdx +++ b/docs/media-buy/task-reference/refine_proposals.mdx @@ -7,8 +7,7 @@ testable: true `refine_proposals` creates revised drafts without committing them. Each entry targets one proposal, proposal IDs must be unique within the request, and results preserve request order. Successful refinement returns a new `proposal_id`; the source proposal remains addressable for comparison or parallel exploration. -{/* Using latest because this schema is not yet released. */} -**Request schema:** [`/schemas/latest/media-buy/refine-proposals-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/refine-proposals-request.json) +**Request schema:** [`/schemas/v3/media-buy/refine-proposals-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/refine-proposals-request.json) ```json { diff --git a/docs/media-buy/task-reference/request_proposals.mdx b/docs/media-buy/task-reference/request_proposals.mdx index 83aac2fe50..c56858080c 100644 --- a/docs/media-buy/task-reference/request_proposals.mdx +++ b/docs/media-buy/task-reference/request_proposals.mdx @@ -9,8 +9,7 @@ testable: true `brand` is required and contains only the stable BrandKey (`domain` plus optional `brand_id`); the seller resolves the canonical brand manifest rather than receiving a full brand file in every call. `account_id` is optional and adds seller-specific commercial terms when the buyer already knows its account. -{/* Using latest because this schema is not yet released. */} -**Request schema:** [`/schemas/latest/media-buy/request-proposals-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/request-proposals-request.json) +**Request schema:** [`/schemas/v3/media-buy/request-proposals-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/request-proposals-request.json) ```json { From f63de06a886c6b36601d260823e2c3bd1dbb22fb Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 11 Aug 2026 02:22:39 +0200 Subject: [PATCH 22/49] fix(schema): discriminate proposal lifecycle responses --- .../task-reference/list_products.mdx | 2 ++ .../task-reference/request_proposals.mdx | 2 ++ server/src/training-agent/task-handlers.ts | 4 ++- .../training-agent-webhooks.test.ts | 1 + .../unit/training-agent-idempotency.test.ts | 26 +++++++++++++++++++ server/tests/unit/training-agent.test.ts | 1 + .../media-buy/list-products-response.json | 23 ++++++++++++---- .../media-buy/refine-proposals-response.json | 15 ++++++----- .../media-buy/request-proposals-response.json | 9 ++++--- tests/composed-schema-validation.test.cjs | 23 +++++++++++++++- 10 files changed, 90 insertions(+), 16 deletions(-) diff --git a/docs/media-buy/task-reference/list_products.mdx b/docs/media-buy/task-reference/list_products.mdx index 6209a95cc3..1ec6966721 100644 --- a/docs/media-buy/task-reference/list_products.mdx +++ b/docs/media-buy/task-reference/list_products.mdx @@ -25,3 +25,5 @@ testable: true `brand` is an identity-only key. Sellers resolve its canonical manifest from the domain; callers do not send logos, colors, or governance overrides in this request. Catalog ingestion belongs to [`sync_catalogs`](/docs/media-buy/task-reference/sync_catalogs); discovery accepts only a compact catalog selection under `criteria.catalog`. The compact criteria deliberately contain only offer characteristics, catalog selection, and policy requirements. The targeting-aware discovery contract is being developed separately in [PR #6203](https://github.com/adcontextprotocol/adcp/pull/6203), so this task does not advertise targeting fields before sellers can enforce them. + +The response uses an explicit `outcome`: `listed` carries `products` (which may be empty), while `unchanged` carries `feed_version` and `cache_scope` without repeating the product page. diff --git a/docs/media-buy/task-reference/request_proposals.mdx b/docs/media-buy/task-reference/request_proposals.mdx index c56858080c..3ee67a185b 100644 --- a/docs/media-buy/task-reference/request_proposals.mdx +++ b/docs/media-buy/task-reference/request_proposals.mdx @@ -23,3 +23,5 @@ testable: true ``` Every returned allocation references the exact `product_id` whose pricing and forecast the seller used. The returned `proposal_id` is the only linkage needed by [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals), [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals), and [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy). + +The response uses `outcome: "proposed"` for a successful draft set and `outcome: "rejected"` with a reason when the seller cannot construct an actionable plan. diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 8a497e534a..71afff5f52 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -4359,7 +4359,7 @@ export function projectProductDiscoveryResult( if (toolName === 'list_products') { if (result.unchanged === true) { return { - unchanged: true, + outcome: 'unchanged', ...(typeof result.wholesale_feed_version === 'string' && { feed_version: result.wholesale_feed_version }), ...(typeof result.pricing_version === 'string' && { pricing_version: result.pricing_version }), ...(typeof result.cache_scope === 'string' && { cache_scope: result.cache_scope }), @@ -4367,6 +4367,7 @@ export function projectProductDiscoveryResult( } const pagination = isRecord(result.pagination) ? result.pagination : undefined; return { + outcome: 'listed', products, ...(pagination && typeof pagination.cursor === 'string' && { next_cursor: pagination.cursor }), ...(typeof result.wholesale_feed_version === 'string' && { feed_version: result.wholesale_feed_version }), @@ -4379,6 +4380,7 @@ export function projectProductDiscoveryResult( if (toolName === 'request_proposals') { const outwardProposals = proposals.map(outwardProposal); return { + outcome: 'proposed', proposals: outwardProposals, products: supportingProductsForProposals(outwardProposals, products), ...(isRecord(result.targeting_resolution) && { targeting_resolution: result.targeting_resolution }), diff --git a/server/tests/integration/training-agent-webhooks.test.ts b/server/tests/integration/training-agent-webhooks.test.ts index f39ea85f6f..1522e3c31c 100644 --- a/server/tests/integration/training-agent-webhooks.test.ts +++ b/server/tests/integration/training-agent-webhooks.test.ts @@ -224,6 +224,7 @@ describe('Training Agent webhook emission', () => { }); const requested = structuredToolResult(requestedResponse); expect(requested).not.toHaveProperty('adcp_error'); + expect(requested.outcome).toBe('proposed'); const source = (requested.proposals as Array>)[0]; const refinedResponse = await call('refine_proposals', { diff --git a/server/tests/unit/training-agent-idempotency.test.ts b/server/tests/unit/training-agent-idempotency.test.ts index 8fa6df67a8..d252744fe3 100644 --- a/server/tests/unit/training-agent-idempotency.test.ts +++ b/server/tests/unit/training-agent-idempotency.test.ts @@ -183,9 +183,33 @@ describe('training agent idempotency middleware', () => { }, }); expect(result.isError).toBeFalsy(); + expect(result.parsed.outcome).toBe('listed'); expect(result.parsed.products).toEqual(expect.any(Array)); }); + it('uses the list_products outcome discriminator for conditional feed reads', async () => { + const first = await call(server, 'list_products', { brand: BRAND }); + expect(first.isError).toBeFalsy(); + expect(first.parsed).toMatchObject({ + outcome: 'listed', + products: expect.any(Array), + feed_version: expect.any(String), + }); + + const unchanged = await call(server, 'list_products', { + brand: BRAND, + if_feed_version: first.parsed.feed_version, + }); + expect(unchanged.isError).toBeFalsy(); + expect(unchanged.parsed).toMatchObject({ + outcome: 'unchanged', + feed_version: first.parsed.feed_version, + cache_scope: 'public', + }); + expect(unchanged.parsed).not.toHaveProperty('products'); + expect(unchanged.parsed).not.toHaveProperty('unchanged'); + }); + it('validates compact linked and envelope fields against the source schema', async () => { const malformedFilter = await call(server, 'list_products', { criteria: { @@ -475,6 +499,7 @@ describe('training agent idempotency middleware', () => { const listed = await call(server, 'list_products', identity); expect(listed.isError).toBeFalsy(); + expect(listed.parsed.outcome).toBe('listed'); expect(listed.parsed.replayed).toBeUndefined(); expect(listed.parsed.products).toEqual(first.parsed.products); }); @@ -583,6 +608,7 @@ describe('training agent idempotency middleware', () => { brand: BRAND, brief: 'cross-channel sports', }); + expect(requested.parsed.outcome).toBe('proposed'); const proposalId = (requested.parsed.proposals as Array<{ proposal_id: string }>)[0].proposal_id; const key = `proposal-refine-${randomUUID()}`; const first = await call(server, 'refine_proposals', { diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index abf670a5a6..6611af5dce 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -12772,6 +12772,7 @@ describe('proposal lifecycle', () => { brief: 'social engagement display', }); expect(requestError).toBeFalsy(); + expect(requested.outcome).toBe('proposed'); expect(requested).not.toHaveProperty('pagination'); expect(requested).not.toHaveProperty('refinement_applied'); const source = (requested.proposals as Array>)[0]; diff --git a/static/schemas/source/media-buy/list-products-response.json b/static/schemas/source/media-buy/list-products-response.json index ee75bbcbe0..22d09015f0 100644 --- a/static/schemas/source/media-buy/list-products-response.json +++ b/static/schemas/source/media-buy/list-products-response.json @@ -4,7 +4,15 @@ "title": "List Products Response", "description": "Product offers and continuation state. This response never contains proposals or proposal-lifecycle fields.", "type": "object", + "discriminator": { + "propertyName": "outcome" + }, "properties": { + "outcome": { + "type": "string", + "enum": ["listed", "unchanged"], + "description": "Whether this response carries a product page or confirms that the selected feed has not changed." + }, "products": { "type": "array", "items": { "$ref": "/schemas/core/product.json" } @@ -17,16 +25,20 @@ "feed_version": { "type": "string", "description": "Opaque version of the selected offer feed." }, "pricing_version": { "type": "string", "description": "Opaque version of the selected pricing layer." }, "cache_scope": { "type": "string", "enum": ["public", "account"] }, - "unchanged": { "type": "boolean", "const": true }, "incomplete": { "$ref": "/schemas/media-buy/get-products-response.json#/properties/incomplete" } }, "oneOf": [ { - "required": ["products"], - "not": { "required": ["unchanged"] } + "properties": { + "outcome": { "type": "string", "const": "listed" } + }, + "required": ["outcome", "products"] }, { - "required": ["unchanged", "feed_version", "cache_scope"], + "properties": { + "outcome": { "type": "string", "const": "unchanged" } + }, + "required": ["outcome", "feed_version", "cache_scope"], "not": { "required": ["products"] } } ], @@ -34,7 +46,8 @@ "anyOf": [ { "required": ["proposals"] }, { "required": ["refinement_applied"] }, - { "required": ["pagination"] } + { "required": ["pagination"] }, + { "required": ["unchanged"] } ] }, "additionalProperties": true diff --git a/static/schemas/source/media-buy/refine-proposals-response.json b/static/schemas/source/media-buy/refine-proposals-response.json index 9e45389e3a..05f6918ba2 100644 --- a/static/schemas/source/media-buy/refine-proposals-response.json +++ b/static/schemas/source/media-buy/refine-proposals-response.json @@ -10,6 +10,9 @@ "minItems": 1, "items": { "type": "object", + "discriminator": { + "propertyName": "outcome" + }, "properties": { "source_proposal_id": { "type": "string", @@ -43,8 +46,8 @@ "required": ["source_proposal_id", "outcome"], "oneOf": [ { - "properties": { "outcome": { "const": "revised" } }, - "required": ["proposal"], + "properties": { "outcome": { "type": "string", "const": "revised" } }, + "required": ["outcome", "proposal"], "not": { "anyOf": [ { "required": ["reason"] }, @@ -53,13 +56,13 @@ } }, { - "properties": { "outcome": { "const": "partial" } }, - "required": ["proposal", "notes"], + "properties": { "outcome": { "type": "string", "const": "partial" } }, + "required": ["outcome", "proposal", "notes"], "not": { "required": ["reason"] } }, { - "properties": { "outcome": { "const": "unable" } }, - "required": ["reason"], + "properties": { "outcome": { "type": "string", "const": "unable" } }, + "required": ["outcome", "reason"], "not": { "required": ["proposal"] } } ], diff --git a/static/schemas/source/media-buy/request-proposals-response.json b/static/schemas/source/media-buy/request-proposals-response.json index 98e99c662f..9a2ad89f04 100644 --- a/static/schemas/source/media-buy/request-proposals-response.json +++ b/static/schemas/source/media-buy/request-proposals-response.json @@ -4,8 +4,11 @@ "title": "Request Proposals Response", "description": "One or more draft media-plan proposals and the exact products referenced by their allocations. Returning only products does not satisfy request_proposals.", "type": "object", + "discriminator": { + "propertyName": "outcome" + }, "properties": { - "outcome": { "type": "string", "const": "rejected" }, + "outcome": { "type": "string", "enum": ["proposed", "rejected"] }, "reason": { "type": "string", "minLength": 1 }, "suggestions": { "type": "array", @@ -36,10 +39,10 @@ }, "oneOf": [ { - "required": ["proposals", "products"], + "properties": { "outcome": { "type": "string", "const": "proposed" } }, + "required": ["outcome", "proposals", "products"], "not": { "anyOf": [ - { "required": ["outcome"] }, { "required": ["reason"] }, { "required": ["suggestions"] } ] diff --git a/tests/composed-schema-validation.test.cjs b/tests/composed-schema-validation.test.cjs index 5515405861..275de7e88b 100644 --- a/tests/composed-schema-validation.test.cjs +++ b/tests/composed-schema-validation.test.cjs @@ -3138,9 +3138,29 @@ async function runTests() { ); await testSchemaValidation( '/schemas/media-buy/list-products-response.json', - { products: [] }, + { outcome: 'listed', products: [] }, 'list_products treats no matches as an empty successful product page' ); + await testSchemaValidation( + '/schemas/media-buy/list-products-response.json', + { outcome: 'unchanged', feed_version: 'feed-v2', cache_scope: 'public' }, + 'list_products explicitly discriminates an unchanged feed response' + ); + await testSchemaValidation( + '/schemas/media-buy/request-proposals-response.json', + { + outcome: 'proposed', + proposals: [{ + proposal_id: 'proposal-1', + name: 'Draft premium video plan', + allocations: [{ product_id: 'premium-video', allocation_percentage: 100 }], + proposal_status: 'draft', + expires_at: '2027-06-30T23:59:59Z' + }], + products: [{ ...productBase, product_id: 'premium-video' }] + }, + 'request_proposals explicitly discriminates a successful proposal response' + ); await testSchemaValidation( '/schemas/media-buy/request-proposals-response.json', { @@ -3165,6 +3185,7 @@ async function runTests() { proposal_status: 'draft', expires_at: '2027-06-30T23:59:59Z' }], + outcome: 'proposed', products: [{ ...productBase, product_id: 'premium-video' }], reason: 'This must not appear on the success arm.' }, From f047befbb5cf1a13bb3fbe8a9b3c87ab98aa3a09 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 11 Aug 2026 02:33:34 +0200 Subject: [PATCH 23/49] docs(media-buy): clarify split lifecycle responses --- .../media-buy/task-reference/get_products.mdx | 2 +- .../task-reference/list_products.mdx | 54 ++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/docs/media-buy/task-reference/get_products.mdx b/docs/media-buy/task-reference/get_products.mdx index 7a0a35f08a..52be9c201f 100644 --- a/docs/media-buy/task-reference/get_products.mdx +++ b/docs/media-buy/task-reference/get_products.mdx @@ -151,7 +151,7 @@ asyncio.run(discover_with_filters()) | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `idempotency_key` | string | No | Optional key for retry-safe use of the 3.x compatibility facade (16–255 characters; letters, digits, `_`, `.`, `:`, and `-`). When supplied, reuse the same key only when retrying the exact initial request, including through the equivalent 3.2 split task. Poll the resulting task through [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json) or MCP task APIs; each new polling request carries its own fresh key. Use a new key whenever any request parameter changes. | +| `idempotency_key` | string | No | Optional key for retry-safe use of the 3.x compatibility facade (16–255 characters; letters, digits, `_`, `.`, `:`, and `-`). When supplied, reuse the same key only when retrying the exact initial request. Poll the resulting task through [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json) or MCP task APIs; each new polling request carries its own fresh key. Use a new key whenever any request parameter changes. | | `buying_mode` | string | Yes | `"brief"`, `"wholesale"`, or `"refine"`. `"brief"`: publisher curates products from the brief. `"wholesale"`: raw product feed access for buyer-directed targeting, `brief` must not be provided. `"refine"`: iterate on products and proposals from a previous response using the `refine` array of change requests. v3 clients MUST include `buying_mode`. Sellers receiving requests from pre-v3 clients without `buying_mode` SHOULD default to `"brief"`. **Timing semantics:** `"wholesale"` is a wholesale product feed read — sellers SHOULD return a synchronous response and MUST NOT route a `"wholesale"` request through the async/Submitted arm. Partial completion is signalled via [`incomplete[]`](#incomplete-array), not a task handoff. `"brief"` and `"refine"` MAY complete synchronously OR MAY return a `Submitted` envelope when curation requires upstream-system queries or HITL review the seller cannot complete inside `time_budget`. Buyers needing predictable fast wholesale product feed access MUST use `"wholesale"`. | | `brief` | string | Conditional | Natural language description of campaign requirements. Required when `buying_mode` is `"brief"`. Must not be provided when `buying_mode` is `"wholesale"` or `"refine"`. | | `refine` | [Refine[]](#refine-array) | Conditional | Array of change requests for iterating on products and proposals. Required when `buying_mode` is `"refine"`. Must not be provided when `buying_mode` is `"brief"` or `"wholesale"`. See [Refine array](#refine-array) below. | diff --git a/docs/media-buy/task-reference/list_products.mdx b/docs/media-buy/task-reference/list_products.mdx index 1ec6966721..d964e94866 100644 --- a/docs/media-buy/task-reference/list_products.mdx +++ b/docs/media-buy/task-reference/list_products.mdx @@ -26,4 +26,56 @@ testable: true The compact criteria deliberately contain only offer characteristics, catalog selection, and policy requirements. The targeting-aware discovery contract is being developed separately in [PR #6203](https://github.com/adcontextprotocol/adcp/pull/6203), so this task does not advertise targeting fields before sellers can enforce them. -The response uses an explicit `outcome`: `listed` carries `products` (which may be empty), while `unchanged` carries `feed_version` and `cache_scope` without repeating the product page. +## Response + +Every response has an explicit `outcome`: + +| Field | Type | Description | +|-------|------|-------------| +| `outcome` | string | `listed` when the response contains a product page; `unchanged` when a conditional request matches the current feed. | +| `products` | Product[] | Product offers for this page. Required for `listed`, even when the array is empty; omitted for `unchanged`. | +| `next_cursor` | string | Cursor for the next page. Omitted on the final page. Pass it as `cursor` in the next request. | +| `feed_version` | string | Opaque version of the selected offer feed. Pass it as `if_feed_version` on a later request to avoid downloading an unchanged feed. | +| `pricing_version` | string | Opaque version of the selected pricing layer. Pass it as `if_pricing_version` together with `if_feed_version` when checking both versions. | +| `cache_scope` | string | Whether the version applies to the seller's `public` feed or an `account`-specific overlay. | +| `incomplete` | object[] | Optional details about portions of the response the seller could not complete. | + +A product page can include continuation and cache state: + +```json +{ + "outcome": "listed", + "products": [], + "next_cursor": "page_2", + "feed_version": "feed_2026_08_10_1", + "pricing_version": "pricing_42", + "cache_scope": "account" +} +``` + +To check whether the same selection has changed, repeat the request with `if_feed_version`. Include `if_pricing_version` only when `if_feed_version` is also present: + +```json +{ + "brand": { "domain": "acmeoutdoor.example" }, + "criteria": { + "offer_filters": { + "channels": ["olv"], + "pricing_currencies": ["USD"] + } + }, + "if_feed_version": "feed_2026_08_10_1", + "if_pricing_version": "pricing_42" +} +``` + +If the selected feed and pricing layer still match, the seller can omit `products`: + +```json +{ + "outcome": "unchanged", + "feed_version": "feed_2026_08_10_1", + "pricing_version": "pricing_42", + "cache_scope": "account" +} +``` From ca7ed454fa552d98da21d8a732b3bbdd08e99945 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 11 Aug 2026 02:47:13 +0200 Subject: [PATCH 24/49] test(media-buy): align split task schema assertions --- server/src/training-agent/tenants/tenant-smoke.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/training-agent/tenants/tenant-smoke.test.ts b/server/src/training-agent/tenants/tenant-smoke.test.ts index 057efb34e5..f3f20f5d9c 100644 --- a/server/src/training-agent/tenants/tenant-smoke.test.ts +++ b/server/src/training-agent/tenants/tenant-smoke.test.ts @@ -1491,7 +1491,7 @@ describe('tenant routing smoke', () => { await close(); } }, 20000); - it('keeps get_products compatible and replays across the AdCP 3.2 split aliases', async () => { + it('keeps get_products compatible while exposing independent AdCP 3.2 split tasks', async () => { const { baseUrl, close } = await bootServer(); try { const url = `${baseUrl}/sales/mcp`; @@ -1561,7 +1561,7 @@ describe('tenant routing smoke', () => { items: { $ref: '#/$defs/media-buy~1proposal-refinement.json' }, }); expect(refineAlias?.inputSchema?.$defs?.['media-buy/proposal-refinement.json']) - .toMatchObject({ type: 'object', required: ['proposal_id'] }); + .toMatchObject({ type: 'object', required: ['proposal_id', 'instructions'] }); const keylessLegacy = await callTenantTool(url, 3, 'get_products', { buying_mode: 'wholesale', @@ -1584,7 +1584,7 @@ describe('tenant routing smoke', () => { }) as { result?: { structuredContent?: { adcp_error?: { code?: string; field?: string } } } }; expect(invalidKeylessList.result?.structuredContent?.adcp_error).toMatchObject({ code: 'INVALID_REQUEST', - field: 'pagination.max_results', + field: 'max_results', }); const malformedAccount = await callTenantTool(url, 33, 'list_products', { From b996428f1c41aff44b3b3ec5c66bcb60c761ce78 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 11 Aug 2026 10:17:32 +0200 Subject: [PATCH 25/49] feat(media-buy): add opportunity proposal lifecycle --- .changeset/secure-get-products-idempotency.md | 2 +- docs.json | 1 + docs/accounts/overview.mdx | 2 +- docs/building/by-layer/L1/security.mdx | 8 +- docs/media-buy/specification.mdx | 2 +- .../task-reference/create_media_buy.mdx | 3 +- .../task-reference/decline_proposals.mdx | 35 ++ .../task-reference/finalize_proposals.mdx | 2 + .../media-buy/task-reference/get_products.mdx | 2 +- docs/media-buy/task-reference/index.mdx | 8 + .../task-reference/refine_proposals.mdx | 6 +- .../task-reference/request_proposals.mdx | 10 +- docs/protocol/get_adcp_capabilities.mdx | 4 +- docs/reference/release-notes.mdx | 6 +- scripts/x-entity-field-map.json | 2 + server/src/training-agent/idempotency.ts | 1 + server/src/training-agent/state.ts | 10 + server/src/training-agent/task-handlers.ts | 491 ++++++++++++++++-- server/src/training-agent/tenants/router.ts | 1 + .../tenants/tenant-smoke.test.ts | 1 + .../training-agent/tenants/tool-catalog.ts | 2 + server/src/training-agent/webhooks.ts | 4 +- .../training-agent-tool-catalog-drift.test.ts | 1 + server/tests/unit/idempotency.test.ts | 2 +- .../product-discovery-schema-parity.test.ts | 4 + .../training-agent-webhook-contract.test.ts | 2 + server/tests/unit/training-agent.test.ts | 406 ++++++++++++++- .../source/core/account-authorization.json | 2 +- .../source/core/async-response-data.json | 5 + .../source/core/opportunity-context.json | 86 +++ static/schemas/source/core/proposal.json | 5 +- .../schemas/source/core/x-entity-types.json | 4 +- .../source/enums/proposal-decline-reason.json | 31 ++ static/schemas/source/enums/task-type.json | 2 + static/schemas/source/index.json | 24 +- .../media-buy/create-media-buy-request.json | 23 +- .../media-buy/create-media-buy-response.json | 6 + .../media-buy/decline-proposals-request.json | 45 ++ .../media-buy/decline-proposals-response.json | 48 ++ .../media-buy/get-products-request.json | 6 +- .../source/media-buy/proposal-decline.json | 34 ++ .../media-buy/request-proposals-request.json | 15 +- .../get-adcp-capabilities-response.json | 2 +- tests/composed-schema-validation.test.cjs | 130 +++++ tests/docs-nav-validation.test.cjs | 1 + 45 files changed, 1415 insertions(+), 72 deletions(-) create mode 100644 docs/media-buy/task-reference/decline_proposals.mdx create mode 100644 static/schemas/source/core/opportunity-context.json create mode 100644 static/schemas/source/enums/proposal-decline-reason.json create mode 100644 static/schemas/source/media-buy/decline-proposals-request.json create mode 100644 static/schemas/source/media-buy/decline-proposals-response.json create mode 100644 static/schemas/source/media-buy/proposal-decline.json diff --git a/.changeset/secure-get-products-idempotency.md b/.changeset/secure-get-products-idempotency.md index 41a5b8206b..b5579edafb 100644 --- a/.changeset/secure-get-products-idempotency.md +++ b/.changeset/secure-get-products-idempotency.md @@ -2,4 +2,4 @@ "adcontextprotocol": minor --- -Add the compact AdCP 3.2 product lifecycle: `list_products`, `request_proposals`, `refine_proposals`, and `finalize_proposals`. The task-specific contracts separate offer discovery from proposal creation, immutable refinement, atomic commitment, and purchase while retaining `get_products` as a key-optional compatibility facade throughout 3.x. +Add the compact AdCP 3.2 product lifecycle: `list_products`, `request_proposals`, `refine_proposals`, `finalize_proposals`, and `decline_proposals`. The task-specific contracts separate offer discovery from proposal creation, immutable refinement, atomic commitment, terminal decline, and purchase while retaining `get_products` as a key-optional compatibility facade throughout 3.x. A shared opportunity reference connects planning-cycle context without duplicating proposal version identity. diff --git a/docs.json b/docs.json index 36b89c35ee..4ed10064a4 100644 --- a/docs.json +++ b/docs.json @@ -297,6 +297,7 @@ "docs/media-buy/task-reference/request_proposals", "docs/media-buy/task-reference/refine_proposals", "docs/media-buy/task-reference/finalize_proposals", + "docs/media-buy/task-reference/decline_proposals", "docs/media-buy/task-reference/create_media_buy", "docs/media-buy/task-reference/sync_catalogs", "docs/media-buy/task-reference/get_media_buys", diff --git a/docs/accounts/overview.mdx b/docs/accounts/overview.mdx index b731aa4c19..0abba72129 100644 --- a/docs/accounts/overview.mdx +++ b/docs/accounts/overview.mdx @@ -144,7 +144,7 @@ Vendor agents that support scope introspection attach an `authorization` object | `scope_name` | Optional named scope identifier. Only `attestation_verifier` is standardized (media-buy-specific, binds to the **AAO Verified (Live)** qualifier); agent-defined names MUST use the `custom:` prefix so typos of the standard value fail schema validation rather than pass through. | | `read_only` | Convenience flag. When true, mutations return `READ_ONLY_SCOPE` regardless of whether the task is in `allowed_tasks`. Omission is equivalent to `false`. Callers MUST NOT infer read-only from `allowed_tasks` alone. | -**AdCP 3.2 compact product tools.** Authorization names the actual task. A `get_products` grant does not silently authorize `request_proposals`, `refine_proposals`, or `finalize_proposals`; sellers grant the proposal lifecycle explicitly and may scope its compact top-level fields independently. A read-only grant may permit `list_products` but rejects the three mutation-capable proposal tools. +**AdCP 3.2 compact product tools.** Authorization names the actual task. A `get_products` grant does not silently authorize `request_proposals`, `refine_proposals`, `finalize_proposals`, or `decline_proposals`; sellers grant the proposal lifecycle explicitly and may scope its compact top-level fields independently. A read-only grant may permit `list_products` but rejects the four mutation-capable proposal tools. ### Semantics of presence and absence diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index 8d73d23146..c549743268 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -399,15 +399,15 @@ A daypart with no declared semantics is ambiguous and MUST be rejected with `INV `idempotency_key` is **required on every state-mutating AdCP task request**. Guaranteed pure-read tasks may leave it optional, but they MUST accept and apply the replay contract when a caller supplies one. Keys are scoped per `(authenticated agent, account)` — they have no meaning across agents on the same seller, across accounts under the same agent, or across sellers. -**Product-discovery migration in 3.2.** AdCP 3.2 adds four compact tools alongside the stable `get_products` facade: +**Product-discovery migration in 3.2.** AdCP 3.2 adds five compact tools alongside the stable `get_products` facade: - `list_products` is a naturally idempotent synchronous read; its key is optional and sellers apply the replay contract when one is supplied. -- `request_proposals`, `refine_proposals`, and `finalize_proposals` require a key because they can allocate tasks or change proposal state. +- `request_proposals`, `refine_proposals`, `finalize_proposals`, and `decline_proposals` require a key because they can allocate tasks or change proposal state. - `get_products` remains valid throughout 3.x as a deprecated compatibility facade, with an optional key. Sellers MUST honor the replay contract whenever the caller supplies one. The compact tools reuse legacy implementation paths where useful but are distinct logical operations. A retry MUST keep the same tool name and payload. Reusing a key on another tool is a different payload and returns `IDEMPOTENCY_CONFLICT`; callers must not switch names after an ambiguous failure. -`request_proposals` binds each opaque proposal ID to its full BrandKey (`domain` plus optional `brand_id`) and, when supplied, its seller account. `refine_proposals` and `finalize_proposals` resolve that binding before idempotency lookup, require every resolvable ID in a batch to share one binding, and scope the key to the resolved account or BrandKey. Callers do not repeat identity data on those mutation requests, and sellers MUST return the same not-found response for an absent proposal and a proposal outside the caller's principal/account scope. When no ID in a batch resolves, sellers use a principal-local negative-lookup scope; this permits safe replay of an all-`unable` refinement without creating a cross-account oracle. +`request_proposals` binds each opaque proposal ID to its full BrandKey (`domain` plus optional `brand_id`) and, when supplied, its seller account. `refine_proposals`, `finalize_proposals`, and `decline_proposals` resolve that binding before idempotency lookup, require every resolvable ID in a batch to share one binding, and scope the key to the resolved account or BrandKey. Callers do not repeat identity data on those mutation requests, and sellers MUST return the same not-found response for an absent proposal and a proposal outside the caller's principal/account scope. When no ID in a batch resolves, sellers use a principal-local negative-lookup scope; this permits safe replay of an all-`unable` result without creating a cross-account oracle. This section applies only to AdCP task requests. OpenRTB bid streams have their own semantics (`BidRequest.id` is a transaction ID, not an idempotency key) and are out of scope. @@ -577,7 +577,7 @@ When in doubt, ask whether the buyer's intent is **"give me the same answer as b **Bootstrap carve-out — `get_adcp_capabilities`.** The discovery call itself is exempt from any schema-level key requirement. `get_adcp_capabilities` is how the buyer learns whether the seller declares `adcp.idempotency.replay_ttl_seconds`, so a fail-closed rule against the discovery call would deadlock the bootstrap. Buyers MAY omit `idempotency_key` on `get_adcp_capabilities`, and sellers MUST accept the call without it. Buyers that send `idempotency_key` on `get_adcp_capabilities` (e.g., SDKs that include the field uniformly) get the standard cache behavior — but the discovery call carries no state and replay is harmless. Every task request that carries a key remains subject to rules 1–9; the fail-closed obligation below applies to retry-sensitive operations once the capability fetch has completed. -**When the seller's capability declaration is missing.** A seller whose `get_adcp_capabilities` response omits `adcp.idempotency.replay_ttl_seconds` is non-compliant. After a successful capability fetch, client SDKs MUST fail closed before any state-mutating task — including `request_proposals`, `refine_proposals`, and `finalize_proposals` — so the buyer learns about the non-compliance before a silent duplicate. A guaranteed pure-read task whose schema leaves the key optional MAY proceed without replay protection, but the client MUST NOT silently treat a retry as deduplicated. +**When the seller's capability declaration is missing.** A seller whose `get_adcp_capabilities` response omits `adcp.idempotency.replay_ttl_seconds` is non-compliant. After a successful capability fetch, client SDKs MUST fail closed before any state-mutating task — including `request_proposals`, `refine_proposals`, `finalize_proposals`, and `decline_proposals` — so the buyer learns about the non-compliance before a silent duplicate. A guaranteed pure-read task whose schema leaves the key optional MAY proceed without replay protection, but the client MUST NOT silently treat a retry as deduplicated. **Decoding seller-emitted error codes.** Sellers MAY return error codes (`IDEMPOTENCY_CONFLICT`, `IDEMPOTENCY_EXPIRED`, `IDEMPOTENCY_IN_FLIGHT`, `INVALID_REQUEST`, or codes added in later minor versions) that buyers' pinned vocabulary may not recognize. Receivers MUST decode these per [Forward-compatible decoding (normative)](/docs/building/by-layer/L3/error-handling#forward-compatible-decoding-normative) — read `error.recovery` for the recovery classification, default to `transient` when `recovery` is absent, and never reject the response because the code value is unfamiliar. The retry semantics for `transient`-classified errors are bounded by [§ Retry Logic](/docs/building/by-layer/L3/error-handling#retry-logic) (`maxRetries` and exponential backoff with jitter) — buyers MUST NOT loop indefinitely on a `transient` default. diff --git a/docs/media-buy/specification.mdx b/docs/media-buy/specification.mdx index db6989f9ec..143b875107 100644 --- a/docs/media-buy/specification.mdx +++ b/docs/media-buy/specification.mdx @@ -548,7 +548,7 @@ Sales agents SHOULD target the following response times: ### Idempotency -Every state-mutating AdCP task request MUST carry an `idempotency_key`. In AdCP 3.2, product discovery uses explicit contracts: `request_proposals`, `refine_proposals`, and `finalize_proposals` require a key; `list_products` and the legacy 3.x `get_products` facade leave it optional. Sales agents MUST apply the replay contract whenever a key is supplied: +Every state-mutating AdCP task request MUST carry an `idempotency_key`. In AdCP 3.2, product discovery uses explicit contracts: `request_proposals`, `refine_proposals`, `finalize_proposals`, and `decline_proposals` require a key; `list_products` and the legacy 3.x `get_products` facade leave it optional. Sales agents MUST apply the replay contract whenever a key is supplied: - The same key and equivalent canonical payload within the replay window MUST return the original response without re-executing the request. - The same key with a different canonical payload MUST return `IDEMPOTENCY_CONFLICT`. diff --git a/docs/media-buy/task-reference/create_media_buy.mdx b/docs/media-buy/task-reference/create_media_buy.mdx index 47756e3809..3ca472ecfe 100644 --- a/docs/media-buy/task-reference/create_media_buy.mdx +++ b/docs/media-buy/task-reference/create_media_buy.mdx @@ -141,7 +141,8 @@ npx @adcp/sdk@latest \ | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `account` | [account-ref](/docs/building/by-layer/L2/accounts-and-agents#account-references) | Yes | Account reference. Pass `{ "account_id": "..." }` or `{ "brand": {...}, "operator": "..." }` if the seller supports implicit resolution. Required for billing and policy evaluation. | -| `proposal_id` | string | No* | ID of a committed proposal from [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals) to execute. Alternative to providing packages. Draft proposals must be finalized first; sellers reject draft proposal execution with [`PROPOSAL_NOT_COMMITTED`](/docs/building/verification/compliance-catalog#error-code-proposal-not-committed). The legacy [`get_products`](/docs/media-buy/task-reference/get_products) finalize form remains supported throughout 3.x. | +| `proposal_id` | string | No* | ID of the exact committed proposal snapshot from [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals). For IDs from the 3.2 split lifecycle, draft proposals fail with [`PROPOSAL_NOT_COMMITTED`](/docs/building/verification/compliance-catalog#error-code-proposal-not-committed), while declined or already executed proposals fail with `INVALID_STATE`. Exact retries reuse the original idempotency key and replay success. Legacy `get_products` proposals retain 3.x behavior. | +| `opportunity` | OpportunityContext | No | Proposal-mode planning-cycle closure shared with `request_proposals` and `decline_proposals`; requires `proposal_id`. Omitting status is the create-specific signal to infer closed with `accepted_with_seller`; if status is sent, it must explicitly carry that closure. | | `total_budget` | TotalBudget | No* | Hard aggregate lifetime budget. Its currency is the single media-buy denomination for package constraints and canonical bidding. Required for proposals and seller-optimized explicit packages. Optional in fixed explicit-package mode; when supplied there, it must equal the sum of package budgets. | | `budget_allocation` | BudgetAllocation | No | Cross-package allocation mode. Omit for fixed allocation. Use `seller_optimized` with media-buy optimization goals to delegate allocation to the seller. Must be omitted when executing a proposal because the committed proposal supplies it. | | `packages` | Package[] | No* | Array of package configurations (see below). Required when not using proposal_id. | diff --git a/docs/media-buy/task-reference/decline_proposals.mdx b/docs/media-buy/task-reference/decline_proposals.mdx new file mode 100644 index 0000000000..8e182008e9 --- /dev/null +++ b/docs/media-buy/task-reference/decline_proposals.mdx @@ -0,0 +1,35 @@ +--- +title: decline_proposals +description: "Terminally decline one or more immutable proposals." +"og:title": "AdCP decline_proposals Task" +testable: true +--- + +`decline_proposals` records that the buyer evaluated one or more proposals and will not pursue them. The operation is terminal: a seller must reject later `finalize_proposals` or new logical `create_media_buy` attempts for a declined proposal with `INVALID_STATE`. Exact retries of an earlier successful call still follow the shared idempotency replay contract. A proposal that was already executed cannot subsequently be declined and returns `unable`. + +{/* Unreleased 3.2 contract: switch to the semver path when 3.2 is published. */} +**Request schema:** [`/schemas/latest/media-buy/decline-proposals-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/decline-proposals-request.json) + +```json +{ + "idempotency_key": "550e8400-e29b-41d4-a716-446655441004", + "declines": [ + { + "proposal_id": "proposal_123", + "reason": "inventory_fit", + "detail": "The available placement mix does not fit this campaign." + } + ], + "opportunity": { + "opportunity_id": "opp_spring_launch_2027", + "status": "closed", + "close_reason": "not_pursued" + } +} +``` + +Each result has an explicit `declined` or `unable` outcome and preserves request order. Retrying with the same idempotency key replays the original response. Declining the same proposal again under a new key also returns `declined` while preserving the first recorded reason and detail, making the terminal state semantically idempotent. + +When `opportunity` is present, every proposal must belong to its `opportunity_id`. Proposal outcomes are independent, but the opportunity update is applied only if every result is `declined`; any `unable` result leaves the opportunity unchanged. + +The request does not carry `proposal_version`. Each `proposal_id` identifies one immutable commercial snapshot; [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) mints a new ID whenever terms change. diff --git a/docs/media-buy/task-reference/finalize_proposals.mdx b/docs/media-buy/task-reference/finalize_proposals.mdx index 84926947e6..3ca526c49d 100644 --- a/docs/media-buy/task-reference/finalize_proposals.mdx +++ b/docs/media-buy/task-reference/finalize_proposals.mdx @@ -17,3 +17,5 @@ testable: true ``` The seller advertises `max_atomic_finalize_batch_size`. Within that bound, finalization is all-or-nothing: every named proposal is persisted as committed, or none is. Retrying an already successful request returns the original committed snapshots and does not extend their inventory holds. + +Finalization changes lifecycle state without changing the proposal's commercial terms, so the committed snapshot retains its `proposal_id`. A declined proposal cannot be finalized; request a new proposal instead. diff --git a/docs/media-buy/task-reference/get_products.mdx b/docs/media-buy/task-reference/get_products.mdx index 52be9c201f..58aea68e38 100644 --- a/docs/media-buy/task-reference/get_products.mdx +++ b/docs/media-buy/task-reference/get_products.mdx @@ -8,7 +8,7 @@ testable: true Discover available advertising products based on campaign requirements using natural language briefs or structured filters. -`get_products` is deprecated for new integrations in AdCP 3.2, but remains fully supported throughout 3.x. Use [`list_products`](/docs/media-buy/task-reference/list_products), [`request_proposals`](/docs/media-buy/task-reference/request_proposals), [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals), or [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals). Its `idempotency_key` remains optional. Split-task retries keep the same tool name; they are not cross-name replays of this compatibility facade. +`get_products` is deprecated for new integrations in AdCP 3.2, but remains fully supported throughout 3.x. Use [`list_products`](/docs/media-buy/task-reference/list_products), [`request_proposals`](/docs/media-buy/task-reference/request_proposals), [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals), [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals), or [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals). Its `idempotency_key` remains optional. Split-task retries keep the same tool name; they are not cross-name replays of this compatibility facade. diff --git a/docs/media-buy/task-reference/index.mdx b/docs/media-buy/task-reference/index.mdx index 5e30245bfb..fb53e6f682 100644 --- a/docs/media-buy/task-reference/index.mdx +++ b/docs/media-buy/task-reference/index.mdx @@ -13,6 +13,11 @@ Complete reference for all AdCP Media Buy tasks. Each task is designed for AI ag | Task | Purpose | Response Time | Phase | |------|---------|---------------|-------| | [`get_products`](/docs/media-buy/task-reference/get_products) | Discover inventory and refine products | ~60s | Discovery | +| [`list_products`](/docs/media-buy/task-reference/list_products) | Enumerate structured product offers | ~1s | Discovery | +| [`request_proposals`](/docs/media-buy/task-reference/request_proposals) | Request seller-authored plans | ~60s | Planning | +| [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) | Create immutable proposal revisions | ~60s | Planning | +| [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals) | Commit proposal terms and inventory holds | Minutes-Days | Planning | +| [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals) | Record terminal proposal feedback | ~1s | Planning | | [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) | Create campaigns from selected products | Minutes-Days | Media Buys | | [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy) | Modify campaign settings and budgets | Minutes-Days | Media Buys | | [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities) | View endpoint capabilities, including creative operations | ~1s | Capability | @@ -65,6 +70,8 @@ Start here to understand what's available and plan your campaign. - **[`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities)** - Discover agent capabilities, portfolio, and supported features (protocol-level task) - **[`get_products`](/docs/media-buy/task-reference/get_products)** - The core discovery task using natural language briefs +- **[`list_products`](/docs/media-buy/task-reference/list_products)** - Compact structured product-offer reads +- **[`request_proposals`](/docs/media-buy/task-reference/request_proposals)**, **[`refine_proposals`](/docs/media-buy/task-reference/refine_proposals)**, **[`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals)**, and **[`decline_proposals`](/docs/media-buy/task-reference/decline_proposals)** - Explicit proposal lifecycle operations - **[Canonical formats](/docs/creative/canonical-formats)** - Understand creative requirements and authority ### Media Buy Management @@ -120,6 +127,7 @@ Schemas are accessible at runtime via the documentation server for validation an Task names use snake_case and follow verb-first semantics consistently across Media Buy: - `get_*`: Retrieve current state or scoped datasets (for example [`get_products`](/docs/media-buy/task-reference/get_products), [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys), [`get_media_buy_delivery`](/docs/media-buy/task-reference/get_media_buy_delivery)) - `list_*`: Enumerate collections with optional filtering (for example [`list_creatives`](/docs/creative/task-reference/list_creatives)) +- `request_*`, `refine_*`, `finalize_*`, and `decline_*`: Explicit lifecycle transitions over immutable resources - `create_*`: Create new resources ([`create_media_buy`](/docs/media-buy/task-reference/create_media_buy)) - `update_*`: Apply partial updates to existing resources ([`update_media_buy`](/docs/media-buy/task-reference/update_media_buy)) - `sync_*`: Reconcile external state into seller systems with upsert-like behavior ([`sync_catalogs`](/docs/media-buy/task-reference/sync_catalogs), [`sync_creatives`](/docs/creative/task-reference/sync_creatives), [`sync_event_sources`](/docs/media-buy/task-reference/sync_event_sources)) diff --git a/docs/media-buy/task-reference/refine_proposals.mdx b/docs/media-buy/task-reference/refine_proposals.mdx index d242c202aa..c77ac63da8 100644 --- a/docs/media-buy/task-reference/refine_proposals.mdx +++ b/docs/media-buy/task-reference/refine_proposals.mdx @@ -5,7 +5,7 @@ description: "Create revised drafts from one or more proposals." testable: true --- -`refine_proposals` creates revised drafts without committing them. Each entry targets one proposal, proposal IDs must be unique within the request, and results preserve request order. Successful refinement returns a new `proposal_id`; the source proposal remains addressable for comparison or parallel exploration. +`refine_proposals` creates revised drafts without committing them. Each entry targets one proposal, proposal IDs must be unique within the request, and results preserve request order. Successful refinement returns a new `proposal_id`; that ID is the complete version identity for the immutable commercial snapshot, and the source remains addressable for comparison or parallel exploration. **Request schema:** [`/schemas/v3/media-buy/refine-proposals-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/refine-proposals-request.json) @@ -25,4 +25,6 @@ testable: true } ``` -The response contains one ordered `results[]` entry per request. `revised` means the requested change was applied, `partial` returns an honest draft plus explanatory notes, and `unable` returns a reason without minting a misleading revision. Refinement never commits pricing or inventory; use [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals) when revisions have converged. +The response contains one ordered `results[]` entry per request. `revised` means the requested change was applied, `partial` returns an honest draft plus explanatory notes, and `unable` returns a reason without minting a misleading revision. Refinement never commits pricing or inventory; use [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals) when revisions have converged or [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals) when the buyer stops pursuing one. + +Every revised proposal inherits the source proposal's opportunity association. Callers do not repeat opportunity context during refinement. diff --git a/docs/media-buy/task-reference/request_proposals.mdx b/docs/media-buy/task-reference/request_proposals.mdx index 3ee67a185b..18148b103a 100644 --- a/docs/media-buy/task-reference/request_proposals.mdx +++ b/docs/media-buy/task-reference/request_proposals.mdx @@ -16,12 +16,20 @@ testable: true "idempotency_key": "550e8400-e29b-41d4-a716-446655441001", "brief": "Reach outdoor enthusiasts with premium video inventory.", "brand": { "domain": "acmeoutdoor.example" }, + "opportunity": { + "opportunity_id": "opp_spring_launch_2027", + "phase": "active_sourcing", + "intent": "live_rfp", + "response_deadline": "2027-01-15T17:00:00Z" + }, "criteria": { "product_ids": ["product_premium_video"] } } ``` -Every returned allocation references the exact `product_id` whose pricing and forecast the seller used. The returned `proposal_id` is the only linkage needed by [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals), [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals), and [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy). +Every returned allocation references the exact `product_id` whose pricing and forecast the seller used. The returned `proposal_id` is the only proposal linkage needed by [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals), [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals), [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals), and [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy). Each ID identifies one immutable commercial snapshot; refinement mints a new ID instead of adding a second version field. + +`opportunity` is optional shared planning-cycle context and must be open when supplied here. Its buyer-assigned `opportunity_id` can span request, decline, and purchase calls without becoming part of proposal identity. Sellers associate it with every proposal created by the request, and revised proposals inherit the same association. The response uses `outcome: "proposed"` for a successful draft set and `outcome: "rejected"` with a reason when the seller cannot construct an actionable plan. diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index 28acc1f4f7..34f49e3846 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -90,7 +90,7 @@ Core AdCP protocol information: #### idempotency -Declares whether this seller honors `idempotency_key` replay protection. AdCP requires keys on mutating requests. In the compact AdCP 3.2 product lifecycle, [`request_proposals`](/docs/media-buy/task-reference/request_proposals), [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals), and [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals) require keys; [`list_products`](/docs/media-buy/task-reference/list_products) and the legacy 3.x [`get_products`](/docs/media-buy/task-reference/get_products) facade leave the key optional. See [security.mdx § Idempotency](/docs/building/by-layer/L1/security#idempotency). Clients MUST NOT assume a default; a seller without this block is non-compliant and should be treated as unsafe for retry-sensitive operations. +Declares whether this seller honors `idempotency_key` replay protection. AdCP requires keys on mutating requests. In the compact AdCP 3.2 product lifecycle, [`request_proposals`](/docs/media-buy/task-reference/request_proposals), [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals), [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals), and [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals) require keys; [`list_products`](/docs/media-buy/task-reference/list_products) and the legacy 3.x [`get_products`](/docs/media-buy/task-reference/get_products) facade leave the key optional. See [security.mdx § Idempotency](/docs/building/by-layer/L1/security#idempotency). Clients MUST NOT assume a default; a seller without this block is non-compliant and should be treated as unsafe for retry-sensitive operations. | Field | Type | Description | |-------|------|-------------| @@ -364,7 +364,7 @@ Media-buy protocol capabilities. Only present if `media_buy` is in `supported_pr #### product_discovery_tools -AdCP 3.2 sellers use `product_discovery_tools` to advertise any supported subset of `get_products`, `list_products`, `request_proposals`, `refine_proposals`, and `finalize_proposals`. When absent, buyers use the legacy `get_products` facade and inspect `buying_modes`. The compact tools share authorization policy with legacy discovery, but each stateful tool has its own idempotency identity: retry with the same tool name and payload. +AdCP 3.2 sellers use `product_discovery_tools` to advertise any supported subset of `get_products`, `list_products`, `request_proposals`, `refine_proposals`, `finalize_proposals`, and `decline_proposals`. When absent, buyers use the legacy `get_products` facade and inspect `buying_modes`. The compact tools share authorization policy with legacy discovery, but each stateful tool has its own idempotency identity: retry with the same tool name and payload. Sellers advertising `finalize_proposals` also declare `max_atomic_finalize_batch_size`. A request within that bound either commits every named proposal or commits none. diff --git a/docs/reference/release-notes.mdx b/docs/reference/release-notes.mdx index 308dd81397..0cee65c2b5 100644 --- a/docs/reference/release-notes.mdx +++ b/docs/reference/release-notes.mdx @@ -15,11 +15,13 @@ Authoritative version-by-version release record for AdCP, with cumulative change ### Compact product and proposal lifecycle (#6115) -AdCP 3.2 adds `list_products`, `request_proposals`, `refine_proposals`, and `finalize_proposals` as compact, task-specific contracts and retains `get_products` throughout 3.x. +AdCP 3.2 adds `list_products`, `request_proposals`, `refine_proposals`, `finalize_proposals`, and `decline_proposals` as compact, task-specific contracts and retains `get_products` throughout 3.x. - `list_products` is a synchronous, naturally idempotent product-offer read. - `request_proposals` creates actionable draft proposals from a brief and optional listed product IDs. -- `refine_proposals` creates immutable proposal revisions; `finalize_proposals` commits one or more revisions atomically. +- `refine_proposals` creates immutable proposal revisions; `finalize_proposals` commits one or more revisions atomically; `decline_proposals` records terminal buyer feedback and prevents later execution. +- `proposal_id` is the complete immutable snapshot identity, so changed terms mint a new ID rather than adding `proposal_version` to every downstream call. +- Optional shared `opportunity` context connects proposal request, decline, and purchase without expanding the identity carried by refinement and finalization. - Each stateful task requires an idempotency key and retains its own replay identity. Retries use the same tool name. - Brand input is an identity-only key, catalogs are compact selections, and offer filters are separated from exact targeting and future targeting support. - `media_buy.product_discovery_tools` advertises the split surface. Its absence means callers use `get_products` and `buying_modes`. diff --git a/scripts/x-entity-field-map.json b/scripts/x-entity-field-map.json index 98d6a9625d..edb391ee5a 100644 --- a/scripts/x-entity-field-map.json +++ b/scripts/x-entity-field-map.json @@ -4,6 +4,8 @@ "media_buy_id": "media_buy", "package_id": "package", "product_id": "product", + "proposal_id": "proposal", + "opportunity_id": "opportunity", "creative_id": "creative", "audience_id": "audience", "evidence_id": "audience_evidence", diff --git a/server/src/training-agent/idempotency.ts b/server/src/training-agent/idempotency.ts index 5d7d758e51..9c4d1043fc 100644 --- a/server/src/training-agent/idempotency.ts +++ b/server/src/training-agent/idempotency.ts @@ -68,6 +68,7 @@ export const MUTATING_TOOLS: ReadonlySet = new Set([ 'creative_approval', 'delete_collection_list', 'delete_property_list', + 'decline_proposals', 'finalize_proposals', 'log_event', 'provide_performance_feedback', diff --git a/server/src/training-agent/state.ts b/server/src/training-agent/state.ts index e1fce679ac..b71f5012b8 100644 --- a/server/src/training-agent/state.ts +++ b/server/src/training-agent/state.ts @@ -122,6 +122,16 @@ export function runWithSessionContext(fn: () => Promise): Promise { return requestCtx.run(ctx, fn); } +/** Force the next getSession() in this request to read durable state again. + * Lifecycle handlers call this after acquiring their shared mutex because + * account-scope derivation may have populated the request cache before a + * competing writer finished. */ +export function evictSessionFromRequestCache(key: string): void { + const ctx = requestCtx.getStore(); + ctx?.sessions.delete(key); + ctx?.snapshots.delete(key); +} + /** * Persist sessions that were actually mutated during the current request. * diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 71afff5f52..aea05f87e5 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -1634,7 +1634,7 @@ import { getAllSignals, SIGNAL_PROVIDERS } from './signal-providers.js'; import { controllerFixturePrincipal, getSession, getProductsSessionKeyFromArgs, sessionKeyFromArgs, findSessionMatching, - runWithSessionContext, flushDirtySessions, + runWithSessionContext, flushDirtySessions, evictSessionFromRequestCache, getComplianceCreatives, getComplianceCreative, getComplianceMediaBuys, getComplianceMediaBuy, MAX_MEDIA_BUYS_PER_SESSION, MAX_CREATIVES_PER_SESSION, MAX_USAGE_RECORDS_PER_SESSION, @@ -2298,7 +2298,10 @@ async function deriveProductDiscoveryAccountScope( ctx: TrainingContext, ): Promise { const directScope = deriveAccountScope(normalizedArgs, isProductDiscoveryTool(toolName)); - if (directScope || (toolName !== 'refine_proposals' && toolName !== 'finalize_proposals')) { + if ( + directScope + || (toolName !== 'refine_proposals' && toolName !== 'finalize_proposals' && toolName !== 'decline_proposals') + ) { return directScope; } @@ -2307,6 +2310,11 @@ async function deriveProductDiscoveryAccountScope( .filter(isRecord) .map(refinement => refinement.proposal_id) .filter((id): id is string => typeof id === 'string') + : toolName === 'decline_proposals' && Array.isArray(originalArgs.declines) + ? originalArgs.declines + .filter(isRecord) + .map(decline => decline.proposal_id) + .filter((id): id is string => typeof id === 'string') : Array.isArray(originalArgs.proposal_ids) ? originalArgs.proposal_ids.filter((id): id is string => typeof id === 'string') : []; @@ -4143,6 +4151,7 @@ const PRODUCT_DISCOVERY_TOOLS = new Set([ 'request_proposals', 'refine_proposals', 'finalize_proposals', + 'decline_proposals', ]); function isProductDiscoveryTool(toolName: string): boolean { @@ -4161,6 +4170,7 @@ function productDiscoverySourceSchemaName(toolName: string): string | undefined case 'request_proposals': return 'request-proposals-request'; case 'refine_proposals': return 'refine-proposals-request'; case 'finalize_proposals': return 'finalize-proposals-request'; + case 'decline_proposals': return 'decline-proposals-request'; default: return undefined; } } @@ -4280,6 +4290,27 @@ export function normalizeProductDiscoveryArgs( : [], }; } + if (toolName === 'decline_proposals') { + const { declines, ...rest } = domainArgs; + return { + ...rest, + buying_mode: 'refine', + __compact_proposal_lifecycle: true, + __decline_proposals: true, + refine: Array.isArray(declines) + ? declines.map(entry => { + if (!isRecord(entry)) return entry; + return { + scope: 'proposal', + proposal_id: entry.proposal_id, + action: 'decline', + reason: entry.reason, + ...(typeof entry.detail === 'string' && { detail: entry.detail }), + }; + }) + : [], + }; + } return args; } @@ -4307,6 +4338,12 @@ function outwardProposal(proposal: Record): Record [proposal.proposal_id, proposal])); + return { + results: requestedDeclines.map(decline => { + const proposalId = typeof decline.proposal_id === 'string' ? decline.proposal_id : ''; + const proposal = proposalsById.get(proposalId); + return proposal?.__declined === true + ? { proposal_id: proposalId, outcome: 'declined' } + : { + proposal_id: proposalId, + outcome: 'unable', + reason: 'The proposal was not found or could not be declined by this authenticated principal.', + }; + }), + }; + } return { proposals: selected.map(outwardProposal) }; } @@ -4478,21 +4534,25 @@ export function validateProductDiscoveryAliasInput( const allowedFields: Record> = { list_products: new Set([ 'adcp_version', 'adcp_major_version', 'idempotency_key', 'context_id', - 'governance_context', 'push_notification_config', 'account_id', 'brand', 'criteria', + 'context', 'governance_context', 'push_notification_config', 'account_id', 'brand', 'criteria', 'fields', 'cursor', 'max_results', 'if_feed_version', 'if_pricing_version', ]), request_proposals: new Set([ 'adcp_version', 'adcp_major_version', 'idempotency_key', 'account_id', - 'context_id', 'governance_context', 'push_notification_config', - 'brand', 'brief', 'criteria', + 'context_id', 'context', 'governance_context', 'push_notification_config', + 'brand', 'brief', 'criteria', 'opportunity', ]), refine_proposals: new Set([ 'adcp_version', 'adcp_major_version', 'idempotency_key', 'refinements', - 'context_id', 'governance_context', 'push_notification_config', + 'context_id', 'context', 'governance_context', 'push_notification_config', ]), finalize_proposals: new Set([ 'adcp_version', 'adcp_major_version', 'idempotency_key', 'proposal_ids', - 'context_id', 'governance_context', 'push_notification_config', + 'context_id', 'context', 'governance_context', 'push_notification_config', + ]), + decline_proposals: new Set([ + 'adcp_version', 'adcp_major_version', 'idempotency_key', 'declines', 'opportunity', + 'context_id', 'context', 'governance_context', 'push_notification_config', ]), }; const allowed = allowedFields[toolName]; @@ -4501,7 +4561,12 @@ export function validateProductDiscoveryAliasInput( if (unknown) return { message: `${unknown} is not supported by ${toolName}`, field: unknown }; } if ( - (toolName === 'request_proposals' || toolName === 'refine_proposals' || toolName === 'finalize_proposals') + ( + toolName === 'request_proposals' + || toolName === 'refine_proposals' + || toolName === 'finalize_proposals' + || toolName === 'decline_proposals' + ) && args.idempotency_key == null ) { return { message: `idempotency_key is required for ${toolName}`, field: 'idempotency_key' }; @@ -4573,6 +4638,42 @@ export function validateProductDiscoveryAliasInput( return { message: 'proposal_ids exceeds max_atomic_finalize_batch_size (25)', field: 'proposal_ids' }; } } + if (toolName === 'decline_proposals') { + if (!Array.isArray(args.declines) || args.declines.length === 0) { + return { message: 'declines must contain at least one proposal decline', field: 'declines' }; + } + if (args.declines.length > 25) { + return { message: 'declines exceeds the maximum batch size (25)', field: 'declines' }; + } + const proposalIds = new Set(); + const reasons = new Set([ + 'price', 'inventory_fit', 'audience_fit', 'creative_unsupported', 'measurement_unsupported', + 'policy', 'timing', 'budget_changed', 'selected_alternative', 'other', + ]); + for (let index = 0; index < args.declines.length; index += 1) { + const entry = args.declines[index]; + if (!isRecord(entry)) { + return { message: 'decline entries must be objects', field: `declines[${index}]` }; + } + if (typeof entry.proposal_id !== 'string' || entry.proposal_id.length === 0) { + return { message: 'proposal_id is required for every decline', field: `declines[${index}].proposal_id` }; + } + if (proposalIds.has(entry.proposal_id)) { + return { message: 'proposal_id values in declines must be unique', field: `declines[${index}].proposal_id` }; + } + proposalIds.add(entry.proposal_id); + if (typeof entry.reason !== 'string' || !reasons.has(entry.reason)) { + return { message: 'a supported reason is required for every decline', field: `declines[${index}].reason` }; + } + if (entry.reason === 'other' && !(typeof entry.detail === 'string' && entry.detail.length > 0)) { + return { message: 'detail is required when decline reason is other', field: `declines[${index}].detail` }; + } + const unknown = Object.keys(entry).find(field => !['proposal_id', 'reason', 'detail'].includes(field)); + if (unknown) { + return { message: `${unknown} is not supported on proposal declines`, field: `declines[${index}].${unknown}` }; + } + } + } return undefined; } @@ -4580,11 +4681,41 @@ const LIST_PRODUCTS_INPUT_SCHEMA = loadProductDiscoveryInputSchema('list-product const REQUEST_PROPOSALS_INPUT_SCHEMA = loadProductDiscoveryInputSchema('request-proposals-request'); const REFINE_PROPOSALS_INPUT_SCHEMA = loadProductDiscoveryInputSchema('refine-proposals-request'); const FINALIZE_PROPOSALS_INPUT_SCHEMA = loadProductDiscoveryInputSchema('finalize-proposals-request'); +const DECLINE_PROPOSALS_INPUT_SCHEMA = loadProductDiscoveryInputSchema('decline-proposals-request'); +const CREATE_MEDIA_BUY_OPPORTUNITY_INPUT_SCHEMA = { + type: 'object', + description: 'Planning-cycle closure for proposal execution. Omit status to infer accepted closure, or send closed with accepted_with_seller.', + properties: { + opportunity_id: { type: 'string', minLength: 1, maxLength: 255, pattern: '^[A-Za-z0-9_.:-]{1,255}$' }, + phase: { type: 'string', enum: ['exploratory', 'planning', 'active_sourcing'] }, + intent: { type: 'string', enum: ['test', 'speculative', 'planning', 'live_rfp'] }, + planning_horizon: { + type: 'object', + properties: { + start: { type: 'string', format: 'date' }, + end: { type: 'string', format: 'date' }, + }, + required: ['start', 'end'], + additionalProperties: true, + }, + response_deadline: { type: 'string', format: 'date-time' }, + status: { type: 'string', const: 'closed' }, + close_reason: { type: 'string', const: 'accepted_with_seller' }, + close_detail: { type: 'string', minLength: 1, maxLength: 500 }, + }, + required: ['opportunity_id'], + allOf: [{ + if: { required: ['status'] }, + then: { required: ['close_reason'] }, + else: { not: { anyOf: [{ required: ['close_reason'] }, { required: ['close_detail'] }] } }, + }], + additionalProperties: false, +}; const TOOLS = [ { name: 'get_products', - description: 'DEPRECATED in AdCP 3.2. Compatibility facade for brief, wholesale, refine, and finalize product flows. New callers use list_products, request_proposals, refine_proposals, and finalize_proposals.', + description: 'DEPRECATED in AdCP 3.2. Compatibility facade for brief, wholesale, refine, and finalize product flows. New callers use the dedicated product-discovery lifecycle tools.', // Polymorphic: brief/wholesale can be reads, but Submitted responses // allocate a task and refine+finalize commits an inventory hold. annotations: { readOnlyHint: false, idempotentHint: true }, @@ -4646,6 +4777,13 @@ const TOOLS = [ execution: { taskSupport: 'optional' as const }, inputSchema: FINALIZE_PROPOSALS_INPUT_SCHEMA, }, + { + name: 'decline_proposals', + description: 'Terminally decline one or more immutable proposals. Repeated declines are semantically idempotent and declined proposals cannot be purchased.', + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, + execution: { taskSupport: 'optional' as const }, + inputSchema: DECLINE_PROPOSALS_INPUT_SCHEMA, + }, { name: 'list_creative_formats', description: 'DEPRECATED in AdCP 3.2. Legacy named-format compatibility projection only. Sales deliverability comes from get_products format_options[]; creative-agent operations come from get_adcp_capabilities creative.supported_formats[].', @@ -4700,7 +4838,7 @@ const TOOLS = [ }, { name: 'create_media_buy', - description: 'Create a media buy with one or more packages targeting specific products. Requires valid product_id and pricing_option_id from get_products. Not for updating existing buys (use update_media_buy). Cannot add packages to an existing buy after creation.', + description: 'Create a media buy either from explicit product packages or by executing one committed proposal_id with total_budget. Package mode uses product_id and pricing_option_id from product discovery. Use update_media_buy for an existing buy.', annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, execution: { taskSupport: 'optional' as const }, inputSchema: { @@ -4731,6 +4869,7 @@ const TOOLS = [ }, }, proposal_id: { type: 'string' }, + opportunity: CREATE_MEDIA_BUY_OPPORTUNITY_INPUT_SCHEMA, total_budget: { type: 'object', properties: { amount: { type: 'number' }, currency: { type: 'string' } } }, start_time: { type: 'string', description: 'ISO 8601 date-time or "asap"' }, end_time: { type: 'string' }, @@ -4755,6 +4894,7 @@ const TOOLS = [ }, }, required: ['account', 'brand', 'start_time', 'end_time'], + dependencies: { opportunity: ['proposal_id'] }, }, }, { @@ -5089,6 +5229,7 @@ export function productDiscoveryAliasToolDefinitions(): Array<(typeof TOOLS)[num || tool.name === 'request_proposals' || tool.name === 'refine_proposals' || tool.name === 'finalize_proposals' + || tool.name === 'decline_proposals' ))); } @@ -5146,6 +5287,10 @@ export async function handleGetProducts(args: ToolArgs, ctx: TrainingContext): P } const buyingMode = req.buying_mode ?? 'brief'; + const proposalLifecycleWrite = buyingMode === 'refine' + || (req as unknown as Record).__require_proposals === true; + const compactLifecycleWrite = proposalLifecycleWrite + && (req as unknown as Record).__compact_proposal_lifecycle === true; const sessionScope = productDiscoverySessionKey(args, ctx); const sessionHash = createHash('sha256').update(sessionScope).digest('hex'); const principal = 'get-products-session-mutex'; @@ -5153,14 +5298,12 @@ export async function handleGetProducts(args: ToolArgs, ctx: TrainingContext): P const store = getIdempotencyStore(); let claim = await store.check({ principal, key, payload: { session: sessionHash } }); - // Read discovery only needs the mutex for a short directive-consumption - // preflight. Queue behind an active writer/preflight instead of surfacing a - // spurious CONFLICT to compare_media_kit-style fan-out. Refine requests keep - // the existing non-blocking conflict behavior because the whole operation - // mutates proposal/pricing state. + // Read discovery and the idempotent split lifecycle queue briefly behind an + // active writer. Legacy get_products refine retains its non-blocking CONFLICT + // behavior for 3.x compatibility. const readLockDeadline = Date.now() + 1_000; let readLockBackoffMs = 5; - while (buyingMode !== 'refine' && claim.kind !== 'miss') { + while ((!proposalLifecycleWrite || compactLifecycleWrite) && claim.kind !== 'miss') { const remainingMs = readLockDeadline - Date.now(); if (remainingMs <= 0) break; const jitterMs = Math.floor(Math.random() * Math.max(1, readLockBackoffMs / 2)); @@ -5181,7 +5324,7 @@ export async function handleGetProducts(args: ToolArgs, ctx: TrainingContext): P }; } - if (buyingMode !== 'refine') { + if (!proposalLifecycleWrite) { let directives: GetProductsReadDirectives = {}; try { const session = await getSession( @@ -5210,6 +5353,7 @@ export async function handleGetProducts(args: ToolArgs, ctx: TrainingContext): P } try { + if (compactLifecycleWrite) evictSessionFromRequestCache(sessionScope); const result = await handleGetProductsUnlocked(args, ctx, paginationOffset); // Keep the mutex until every refine mutation is durable, not just proposal // holds, so a following refine request observes the committed context. @@ -5435,11 +5579,20 @@ async function handleGetProductsUnlocked( } } - // Refine mode: apply include/omit/more_like_this/finalize + // Refine mode: apply include/omit/more_like_this/finalize. The dedicated + // decline_proposals task uses the same internal transaction machinery but + // is intentionally not part of the deprecated get_products wire schema. type RefineEntry = | { scope: 'request'; ask?: string } | { scope: 'product'; product_id: string; action?: 'include' | 'omit' | 'more_like_this'; ask?: string } - | { scope: 'proposal'; proposal_id: string; action?: 'include' | 'omit' | 'finalize'; ask?: string }; + | { + scope: 'proposal'; + proposal_id: string; + action?: 'include' | 'omit' | 'finalize' | 'decline'; + ask?: string; + reason?: string; + detail?: string; + }; type RefinementAppliedEntry = | { scope: 'request'; status: 'applied' | 'partial' | 'unable'; notes?: string } @@ -5448,19 +5601,27 @@ async function handleGetProductsUnlocked( const immutableRefine = buyingMode === 'refine' && (req as unknown as Record).__immutable_refine === true; + const compactLifecycle = buyingMode === 'refine' + && (req as unknown as Record).__compact_proposal_lifecycle === true; + const declineProposals = buyingMode === 'refine' + && (req as unknown as Record).__decline_proposals === true; const refinementApplied: RefinementAppliedEntry[] = []; const proposalOmitIds = new Set(); const refinedProposalOverrides = new Map(); const explicitlySelectedProposals = new Map(); const stagedProposalCommits = new Map(); + const stagedProposalDeclines = new Map(); let guaranteedOnlyRequested = false; if (buyingMode === 'refine' && req.refine) { const refineOps = req.refine as unknown as RefineEntry[]; - const previousProposals = session.lastGetProductsContext?.proposals || getProposals(); + const previousProposals = session.lastGetProductsContext?.proposals + ?? (compactLifecycle ? [] : getProposals()); const registryProposals = getProposals(); const resolveProposal = (proposalId: string): Proposal | undefined => { const proposal = previousProposals.find(candidate => candidate.proposal_id === proposalId) - ?? registryProposals.find(candidate => candidate.proposal_id === proposalId); + ?? (compactLifecycle + ? undefined + : registryProposals.find(candidate => candidate.proposal_id === proposalId)); if (proposal) return proposal; if (isThreeZeroStoryboardCompat(ctx) && proposalId === THREE_ZERO_LEGACY_PROPOSAL_ID) { return resolveThreeZeroProposalAlias([...previousProposals, ...registryProposals]); @@ -5477,6 +5638,11 @@ async function handleGetProductsUnlocked( const askAckNotes = (ask?: string) => ask ? { notes: `Ask acknowledged but not applied by training agent: ${ask}` } : {}; + const declineOpportunity = declineProposals + && isRecord((req as unknown as Record).opportunity) + ? (req as unknown as Record).opportunity as Record + : undefined; + let everyDeclineApplicable = true; // Validate entity references before applying any refinements. This keeps // failed multi-entry refine calls from partially finalizing earlier entries. @@ -5498,7 +5664,8 @@ async function handleGetProductsUnlocked( if (op.scope !== 'proposal') continue; const proposal = resolveProposal(op.proposal_id); if (!proposal) { - if (immutableRefine) continue; + if (declineProposals) everyDeclineApplicable = false; + if (immutableRefine || declineProposals) continue; return { errors: [{ code: 'PROPOSAL_NOT_FOUND', @@ -5508,7 +5675,34 @@ async function handleGetProductsUnlocked( }] as TaskError[], }; } + if (op.action === 'decline') { + const internal = proposal as unknown as Record; + if (internal.__executed === true) everyDeclineApplicable = false; + if ( + typeof declineOpportunity?.opportunity_id === 'string' + && internal.__opportunity_id !== declineOpportunity.opportunity_id + ) { + return { + errors: [{ + code: 'INVALID_REQUEST', + message: 'Every proposal in decline_proposals must belong to the supplied opportunity_id.', + field: 'opportunity.opportunity_id', + recovery: 'correctable', + }] as TaskError[], + }; + } + } if (op.action === 'finalize') { + if ((proposal as unknown as Record).__declined === true) { + return { + errors: [{ + code: 'INVALID_STATE', + message: `Proposal has been declined and cannot be finalized: ${op.proposal_id}`, + field: `refine[${opIndex}].proposal_id`, + recovery: 'correctable', + }] as TaskError[], + }; + } if (proposal.expires_at && new Date(proposal.expires_at) < new Date()) { return { errors: [{ @@ -5614,6 +5808,15 @@ async function handleGetProductsUnlocked( proposalOmitIds.add(proposal.proposal_id); refinementApplied.push({ scope: 'proposal', proposal_id: op.proposal_id, status: 'applied' }); } else if (action === 'include') { + if (immutableRefine && (proposal as unknown as Record).__declined === true) { + refinementApplied.push({ + scope: 'proposal', + proposal_id: op.proposal_id, + status: 'unable', + notes: 'Proposal was declined terminally and cannot be refined', + }); + continue; + } explicitlySelectedProposals.set(proposal.proposal_id, proposal); for (const allocation of proposal.allocations) includeIds.add(allocation.product_id); if (proposalLifecycle(proposal).proposal_status === 'committed' && op.ask) { @@ -5717,6 +5920,53 @@ async function handleGetProductsUnlocked( refinementApplied.push({ scope: 'proposal', proposal_id: op.proposal_id, status: 'applied', notes: 'Proposal finalized — pricing committed, inventory held for 24 hours' }); } + } else if (action === 'decline') { + const internal = proposal as unknown as Record; + if (internal.__executed === true) { + refinementApplied.push({ + scope: 'proposal', + proposal_id: op.proposal_id, + status: 'unable', + notes: 'Proposal was already executed and cannot be declined', + }); + continue; + } + const opportunityUpdate = everyDeclineApplicable && declineOpportunity + ? { + ...(isRecord(internal.__opportunity_update) + ? structuredClone(internal.__opportunity_update) + : {}), + ...structuredClone(declineOpportunity), + } + : undefined; + if (opportunityUpdate?.status === 'open') { + delete opportunityUpdate.close_reason; + delete opportunityUpdate.close_detail; + } + const declined = { + ...proposal, + ...( + internal.__declined === true + ? {} + : { + __declined: true, + ...(typeof op.reason === 'string' && { __decline_reason: op.reason }), + ...(typeof op.detail === 'string' && { __decline_detail: op.detail }), + } + ), + ...(opportunityUpdate && { __opportunity_update: opportunityUpdate }), + } as unknown as Proposal; + stagedProposalDeclines.set(op.proposal_id, declined); + refinedProposalOverrides.set(op.proposal_id, declined); + explicitlySelectedProposals.set(op.proposal_id, declined); + refinementApplied.push({ + scope: 'proposal', + proposal_id: op.proposal_id, + status: 'applied', + notes: (proposal as unknown as Record).__declined === true + ? 'Proposal was already declined' + : 'Proposal declined terminally', + }); } } else if (op.scope === 'request') { if (requestsGuaranteedOnlyProducts(op.ask)) { @@ -5754,14 +6004,15 @@ async function handleGetProductsUnlocked( if (guaranteedOnlyRequested) { products = products.filter(product => product.delivery_type === 'guaranteed'); } - if (stagedProposalCommits.size > 0) { + if (stagedProposalCommits.size > 0 || stagedProposalDeclines.size > 0) { // Publish the complete batch with one assignment only after every // proposal has been resolved and every committed snapshot constructed. // This is the training agent's transaction boundary: no request can // observe a prefix of an atomic finalize_proposals batch. const prior = session.lastGetProductsContext?.proposals ?? []; - const next = prior.map(proposal => stagedProposalCommits.get(proposal.proposal_id) ?? proposal); - for (const [proposalId, proposal] of stagedProposalCommits) { + const stagedUpdates = new Map([...stagedProposalCommits, ...stagedProposalDeclines]); + const next = prior.map(proposal => stagedUpdates.get(proposal.proposal_id) ?? proposal); + for (const [proposalId, proposal] of stagedUpdates) { if (!prior.some(existing => existing.proposal_id === proposalId)) next.push(proposal); } session.lastGetProductsContext = { @@ -5849,6 +6100,7 @@ async function handleGetProductsUnlocked( const requestRecord = req as unknown as Record; const requestAccount = isRecord(requestRecord.account) ? requestRecord.account : undefined; const requestBrand = isRecord(requestRecord.brand) ? requestRecord.brand : undefined; + const requestOpportunity = isRecord(requestRecord.opportunity) ? requestRecord.opportunity : undefined; const proposalOwner = JSON.stringify({ ...(typeof requestAccount?.account_id === 'string' && { account_id: requestAccount.account_id }), brand: { @@ -5870,6 +6122,8 @@ async function handleGetProductsUnlocked( ...(typeof requestBrand?.domain === 'string' && { __brand_domain: requestBrand.domain.toLowerCase() }), ...(typeof requestBrand?.brand_id === 'string' && { __brand_id: requestBrand.brand_id }), ...(typeof requestAccount?.account_id === 'string' && { __account_id: requestAccount.account_id }), + ...(typeof requestOpportunity?.opportunity_id === 'string' + && { __opportunity_id: requestOpportunity.opportunity_id }), }; }); if (proposals.length === 0) { @@ -6919,11 +7173,59 @@ export async function handleValidateInput(args: ToolArgs, ctx: TrainingContext): } export async function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) { + const proposalId = (args as unknown as Record).proposal_id; + if (typeof proposalId !== 'string') return handleCreateMediaBuyUnlocked(args, ctx); + + // Proposal execution, finalization, and decline all transition the same + // principal-owned snapshot. Serialize them on the compact lifecycle session + // and persist before releasing so different idempotency keys cannot both + // execute one proposal. + const sessionScope = sessionKeyFromArgs( + {}, + ctx.mode, + ctx.userId, + ctx.moduleId, + ctx.principal ?? 'anonymous', + ); + const sessionHash = createHash('sha256').update(sessionScope).digest('hex'); + const principal = 'get-products-session-mutex'; + const key = `get-products-session:${sessionHash}`; + const store = getIdempotencyStore(); + let claim = await store.check({ principal, key, payload: { session: sessionHash } }); + const deadline = Date.now() + 2_000; + let backoffMs = 5; + while (claim.kind !== 'miss' && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, backoffMs)); + claim = await store.check({ principal, key, payload: { session: sessionHash } }); + backoffMs = Math.min(backoffMs * 2, 100); + } + if (claim.kind !== 'miss') { + return { + errors: [{ + code: 'CONFLICT', + message: 'Another proposal lifecycle request is already updating this session. Retry after a short delay.', + recovery: 'transient', + }] as TaskError[], + }; + } + + try { + evictSessionFromRequestCache(sessionScope); + const result = await handleCreateMediaBuyUnlocked(args, ctx); + await flushDirtySessions(); + return result; + } finally { + await store.release({ principal, key, claimToken: claim.claimToken }); + } +} + +async function handleCreateMediaBuyUnlocked(args: ToolArgs, ctx: TrainingContext) { const req = args as unknown as CreateMediaBuyRequest & ToolArgs & { paused?: boolean }; const session = await getSession( sessionKeyFromArgs(req, ctx.mode, ctx.userId, ctx.moduleId), controllerFixtureSessionKey(req, ctx), ); + let executedCompactProposal: Proposal | undefined; // Consume any single-shot directive registered by // comply_test_controller.force_create_media_buy_arm. Runs before all other @@ -7330,6 +7632,48 @@ export async function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) }; } + const internalProposal = proposal as unknown as Record; + const compactProposal = typeof internalProposal.__brand_domain === 'string' + || typeof internalProposal.__brand_id === 'string' + || typeof internalProposal.__account_id === 'string'; + if (internalProposal.__declined === true) { + return { + errors: [{ + code: 'INVALID_STATE', + message: `Proposal "${req.proposal_id}" has been declined and cannot be executed. Request a new proposal before retrying.`, + field: 'proposal_id', + recovery: 'correctable', + }] as TaskError[], + }; + } + if (compactProposal && internalProposal.__executed === true) { + return { + errors: [{ + code: 'INVALID_STATE', + message: `Proposal "${req.proposal_id}" was already executed. Exact retries must reuse the original idempotency key.`, + field: 'proposal_id', + recovery: 'correctable', + }] as TaskError[], + }; + } + const createOpportunity = isRecord((req as unknown as Record).opportunity) + ? (req as unknown as Record).opportunity as Record + : undefined; + if ( + typeof internalProposal.__opportunity_id === 'string' + && typeof createOpportunity?.opportunity_id === 'string' + && createOpportunity.opportunity_id !== internalProposal.__opportunity_id + ) { + return { + errors: [{ + code: 'INVALID_REQUEST', + message: 'opportunity.opportunity_id does not match the opportunity associated with this proposal.', + field: 'opportunity.opportunity_id', + recovery: 'correctable', + }] as TaskError[], + }; + } + // Enforce proposal lifecycle: draft proposals cannot be purchased directly const proposalStatus = proposalLifecycle(proposal).proposal_status; if (proposalStatus === 'draft' && !(isThreeZeroStoryboardCompat(ctx) && req.proposal_id === THREE_ZERO_LEGACY_PROPOSAL_ID)) { @@ -7389,6 +7733,7 @@ export async function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) ...(bidPrice !== undefined && { bid_price: bidPrice }), }; }); + if (compactProposal) executedCompactProposal = proposal; } if (!req.packages?.length) { @@ -7723,6 +8068,26 @@ export async function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) }], }; + if (executedCompactProposal) { + const internal = executedCompactProposal as unknown as Record; + internal.__executed = true; + const suppliedOpportunity = isRecord((req as unknown as Record).opportunity) + ? (req as unknown as Record).opportunity as Record + : undefined; + const opportunityId = typeof suppliedOpportunity?.opportunity_id === 'string' + ? suppliedOpportunity.opportunity_id + : typeof internal.__opportunity_id === 'string' + ? internal.__opportunity_id + : undefined; + if (opportunityId) { + internal.__opportunity_update = { + ...structuredClone(suppliedOpportunity ?? {}), + opportunity_id: opportunityId, + status: 'closed', + close_reason: 'accepted_with_seller', + }; + } + } session.mediaBuys.set(mediaBuyId, mediaBuy); const status = deriveStatus(mediaBuy, session); @@ -7735,6 +8100,7 @@ export async function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) // against the per-task response schema. return { media_buy_id: mediaBuyId, + ...(req.proposal_id && { proposal_id: req.proposal_id }), ...(req.idempotency_key && { idempotency_key: req.idempotency_key }), media_buy_status: status, revision: mediaBuy.revision, @@ -11266,6 +11632,7 @@ const HANDLER_MAP: Record = { request_proposals: handleGetProducts, refine_proposals: handleGetProducts, finalize_proposals: handleGetProducts, + decline_proposals: handleGetProducts, list_creative_formats: handleListCreativeFormats, validate_input: handleValidateInput, create_media_buy: handleCreateMediaBuy, @@ -11326,17 +11693,55 @@ function validateIdempotencyProtectedInput( if (args.brief !== undefined && typeof args.brief !== 'string') { return { message: 'brief must be a string when provided', field: 'brief' }; } - const parsed = GetProductsRequestSchema.safeParse(args); - if (!parsed.success) { - const issue = parsed.error.issues[0]; - const field = issue?.path.map(segment => String(segment)).join('.'); - return { - message: `Invalid get_products request${field ? ` at ${field}` : ''}: ${issue?.message ?? 'schema validation failed'}`, - ...(field && { field }), - }; + const hasDecline = Array.isArray(args.refine) && args.refine.some(entry => ( + isRecord(entry) && entry.scope === 'proposal' && entry.action === 'decline' + )); + const compactDecline = args.__decline_proposals === true; + if (hasDecline) { + if (!compactDecline) { + return { + message: 'Proposal decline is available through the dedicated decline_proposals task.', + field: 'refine', + }; + } + const reasons = new Set([ + 'price', 'inventory_fit', 'audience_fit', 'creative_unsupported', 'measurement_unsupported', + 'policy', 'timing', 'budget_changed', 'selected_alternative', 'other', + ]); + const invalidIndex = (args.refine as unknown[]).findIndex(entry => ( + isRecord(entry) + && entry.scope === 'proposal' + && entry.action === 'decline' + && ( + typeof entry.proposal_id !== 'string' + || typeof entry.reason !== 'string' + || !reasons.has(entry.reason) + || (entry.reason === 'other' && !(typeof entry.detail === 'string' && entry.detail.length > 0)) + ) + )); + if (invalidIndex >= 0) { + return { + message: `Invalid get_products request at refine[${invalidIndex}]: decline requires proposal_id, a supported reason, and detail when reason is other.`, + field: `refine[${invalidIndex}]`, + }; + } } - if (parsed.data.pagination && decodeOffsetCursor('products', parsed.data.pagination.cursor) === null) { - return { message: 'pagination.cursor is malformed', field: 'pagination.cursor' }; + // A dedicated decline request was validated against its public source schema + // before normalization. All public get_products shapes continue through the + // SDK parser, whose schema intentionally does not expose the internal action. + if (!compactDecline) { + const parsed = GetProductsRequestSchema.safeParse(args); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const field = issue?.path.map(segment => String(segment)).join('.'); + return { + message: `Invalid get_products request${field ? ` at ${field}` : ''}: ${issue?.message ?? 'schema validation failed'}`, + ...(field && { field }), + }; + } + if (parsed.data.pagination && decodeOffsetCursor('products', parsed.data.pagination.cursor) === null) { + return { message: 'pagination.cursor is malformed', field: 'pagination.cursor' }; + } } // Finalization is a commit boundary, not another refinement. Reject mixed @@ -11446,8 +11851,12 @@ async function executeTrainingAgentToolInContext( return { success: false, error: `idempotency_key is required for ${toolName}` }; } const sourceSchemaName = productDiscoverySourceSchemaName(toolName); - const sourceValidationError = sourceSchemaName - ? validateProductDiscoverySourceInput(sourceSchemaName, rawArgs) + const strictSourceSchemaName = sourceSchemaName + ?? (toolName === 'create_media_buy' && rawArgs.opportunity !== undefined + ? 'create-media-buy-request' + : undefined); + const sourceValidationError = strictSourceSchemaName + ? validateProductDiscoverySourceInput(strictSourceSchemaName, rawArgs) : undefined; if (sourceValidationError) { return { success: false, error: sourceValidationError.message }; @@ -11673,8 +12082,12 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { } const sourceSchemaName = productDiscoverySourceSchemaName(name); - const sourceValidationError = sourceSchemaName - ? validateProductDiscoverySourceInput(sourceSchemaName, rawArgs) + const strictSourceSchemaName = sourceSchemaName + ?? (name === 'create_media_buy' && rawArgs.opportunity !== undefined + ? 'create-media-buy-request' + : undefined); + const sourceValidationError = strictSourceSchemaName + ? validateProductDiscoverySourceInput(strictSourceSchemaName, rawArgs) : undefined; if (sourceValidationError) { return { diff --git a/server/src/training-agent/tenants/router.ts b/server/src/training-agent/tenants/router.ts index cf4c31112b..9a29e4caf5 100644 --- a/server/src/training-agent/tenants/router.ts +++ b/server/src/training-agent/tenants/router.ts @@ -136,6 +136,7 @@ const PRODUCT_DISCOVERY_TOOL_NAMES = [ 'request_proposals', 'refine_proposals', 'finalize_proposals', + 'decline_proposals', ] as const; function bearerToken(req: Request): string | undefined { diff --git a/server/src/training-agent/tenants/tenant-smoke.test.ts b/server/src/training-agent/tenants/tenant-smoke.test.ts index f3f20f5d9c..89771ee61b 100644 --- a/server/src/training-agent/tenants/tenant-smoke.test.ts +++ b/server/src/training-agent/tenants/tenant-smoke.test.ts @@ -1545,6 +1545,7 @@ describe('tenant routing smoke', () => { 'request_proposals', 'refine_proposals', 'finalize_proposals', + 'decline_proposals', ])); const listAlias = listBody.result?.tools?.find(tool => tool.name === 'list_products'); const recommendAlias = listBody.result?.tools?.find(tool => tool.name === 'request_proposals'); diff --git a/server/src/training-agent/tenants/tool-catalog.ts b/server/src/training-agent/tenants/tool-catalog.ts index 42ac074c72..7e1b6e100e 100644 --- a/server/src/training-agent/tenants/tool-catalog.ts +++ b/server/src/training-agent/tenants/tool-catalog.ts @@ -38,6 +38,7 @@ export const TOOL_CATALOG: Readonly> = { request_proposals: ['sales'], refine_proposals: ['sales'], finalize_proposals: ['sales'], + decline_proposals: ['sales'], create_media_buy: ['sales'], update_media_buy: ['sales'], get_media_buys: ['sales'], @@ -140,6 +141,7 @@ export function toolsForTenant( || tool === 'request_proposals' || tool === 'refine_proposals' || tool === 'finalize_proposals' + || tool === 'decline_proposals' ) return false; if (tool === 'validate_input' || tool === 'list_transformers') return false; if (tool === 'sync_governance' && tenantId === 'sales') return false; diff --git a/server/src/training-agent/webhooks.ts b/server/src/training-agent/webhooks.ts index 498ed66dbb..7ee373a78c 100644 --- a/server/src/training-agent/webhooks.ts +++ b/server/src/training-agent/webhooks.ts @@ -37,7 +37,7 @@ const logger = createLogger('training-agent-webhooks'); * Keep in sync with `static/schemas/source/core/mcp-webhook-payload.json`. */ export type WebhookTaskType = | 'create_media_buy' | 'update_media_buy' | 'sync_creatives' | 'build_creative' - | 'get_products' | 'request_proposals' | 'refine_proposals' | 'finalize_proposals' + | 'get_products' | 'request_proposals' | 'refine_proposals' | 'finalize_proposals' | 'decline_proposals' | 'activate_signal' | 'get_signals' | 'create_property_list' | 'update_property_list' | 'get_property_list' | 'list_property_lists' | 'delete_property_list' | 'sync_accounts' @@ -50,6 +50,7 @@ export const TOOL_TO_TASK_TYPE = { request_proposals: 'request_proposals', refine_proposals: 'refine_proposals', finalize_proposals: 'finalize_proposals', + decline_proposals: 'decline_proposals', create_media_buy: 'create_media_buy', update_media_buy: 'update_media_buy', sync_creatives: 'sync_creatives', @@ -90,6 +91,7 @@ export const TOOL_TO_PROTOCOL: Readonly { 'request_proposals', 'refine_proposals', 'finalize_proposals', + 'decline_proposals', ]; for (const tool of splitTools) { expect(compatibilityCatalog).not.toContain(tool); diff --git a/server/tests/unit/idempotency.test.ts b/server/tests/unit/idempotency.test.ts index 25bef78429..8a590e681c 100644 --- a/server/tests/unit/idempotency.test.ts +++ b/server/tests/unit/idempotency.test.ts @@ -72,7 +72,7 @@ describe('idempotency facade', () => { it('covers specific mutating tools explicitly', () => { for (const name of [ 'create_media_buy', 'update_media_buy', 'sync_audiences', - 'request_proposals', 'refine_proposals', 'finalize_proposals', + 'request_proposals', 'refine_proposals', 'finalize_proposals', 'decline_proposals', 'si_initiate_session', 'si_send_message', 'acquire_rights', 'update_rights', 'creative_approval', ]) { diff --git a/server/tests/unit/product-discovery-schema-parity.test.ts b/server/tests/unit/product-discovery-schema-parity.test.ts index 6e5bb7b2ca..94631a3a88 100644 --- a/server/tests/unit/product-discovery-schema-parity.test.ts +++ b/server/tests/unit/product-discovery-schema-parity.test.ts @@ -30,6 +30,7 @@ describe('product discovery MCP schema parity', () => { ['request_proposals', 'request-proposals-request'], ['refine_proposals', 'refine-proposals-request'], ['finalize_proposals', 'finalize-proposals-request'], + ['decline_proposals', 'decline-proposals-request'], ] as const) { const runtime = tools.get(toolName)!; const source = sourceSchema(fileName); @@ -81,6 +82,9 @@ describe('product discovery MCP schema parity', () => { expect(resolveLocalRef(list, list.properties.criteria)).toMatchObject({ additionalProperties: false }); expect(resolveLocalRef(refineTool, refineTool.properties.refinements.items)) .toMatchObject({ required: ['proposal_id', 'instructions'], additionalProperties: false }); + const declineTool = tools.get('decline_proposals')!; + expect(resolveLocalRef(declineTool, declineTool.properties.declines.items)) + .toMatchObject({ required: ['proposal_id', 'reason'], additionalProperties: false }); }); it('bundles each tools/list input schema as a valid standalone document', () => { diff --git a/server/tests/unit/training-agent-webhook-contract.test.ts b/server/tests/unit/training-agent-webhook-contract.test.ts index 65858fa643..d4ffaa4a38 100644 --- a/server/tests/unit/training-agent-webhook-contract.test.ts +++ b/server/tests/unit/training-agent-webhook-contract.test.ts @@ -21,6 +21,7 @@ describe('training-agent completion webhook contract', () => { request_proposals: 'request_proposals', refine_proposals: 'refine_proposals', finalize_proposals: 'finalize_proposals', + decline_proposals: 'decline_proposals', build_creative: 'build_creative', update_rights: 'update_rights', }); @@ -29,6 +30,7 @@ describe('training-agent completion webhook contract', () => { request_proposals: 'media-buy', refine_proposals: 'media-buy', finalize_proposals: 'media-buy', + decline_proposals: 'media-buy', build_creative: 'creative', update_rights: 'brand', }); diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index 6611af5dce..2c635d867d 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -1383,6 +1383,7 @@ describe('createTrainingAgentServer', () => { expect(toolNames).toContain('request_proposals'); expect(toolNames).toContain('refine_proposals'); expect(toolNames).toContain('finalize_proposals'); + expect(toolNames).toContain('decline_proposals'); expect(toolNames).toContain('list_creative_formats'); expect(toolNames).toContain('create_media_buy'); expect(toolNames).toContain('get_media_buys'); @@ -1433,7 +1434,7 @@ describe('createTrainingAgentServer', () => { expect(toolNames).toContain('update_collection_list'); expect(toolNames).toContain('list_collection_lists'); expect(toolNames).toContain('delete_collection_list'); - expect(toolNames).toHaveLength(55); + expect(toolNames).toHaveLength(56); const validateInput = tools.find(t => t.name === 'validate_input'); expect(validateInput?.inputSchema?.properties?.targets?.maxItems).toBe(50); @@ -12765,11 +12766,46 @@ describe('proposal lifecycle', () => { const account = { brand: { domain: 'proposal-test.example' }, operator: 'proposal-test.example' }; + it('serializes concurrent proposal requests without losing returned snapshots', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const requests = await Promise.all([ + simulateCallTool(server, 'request_proposals', { + idempotency_key: `test-${randomUUID()}`, + brand: account.brand, + brief: 'social engagement display', + }), + simulateCallTool(server, 'request_proposals', { + idempotency_key: `test-${randomUUID()}`, + brand: account.brand, + brief: 'social engagement display', + }), + ]); + expect(requests.every(request => !request.isError)).toBe(true); + const returnedIds = requests.flatMap(request => ( + request.result.proposals as Array> + ).map(proposal => proposal.proposal_id as string)); + + await runWithSessionContext(async () => { + const session = await getSession( + sessionKeyFromArgs({}, DEFAULT_CTX.mode, undefined, undefined, 'anonymous'), + ); + const storedIds = new Set( + (session.lastGetProductsContext?.proposals ?? []).map(proposal => proposal.proposal_id), + ); + expect(returnedIds.every(proposalId => storedIds.has(proposalId))).toBe(true); + }); + }); + it('connects the compact request, refine, finalize, and purchase lifecycle', async () => { const server = createTrainingAgentServer(DEFAULT_CTX); + const lifecycleOpportunity = { + opportunity_id: 'opp-compact-purchase-2027', + status: 'open', + }; const { result: requested, isError: requestError } = await simulateCallTool(server, 'request_proposals', { brand: account.brand, brief: 'social engagement display', + opportunity: lifecycleOpportunity, }); expect(requestError).toBeFalsy(); expect(requested.outcome).toBe('proposed'); @@ -12816,7 +12852,8 @@ describe('proposal lifecycle', () => { proposal_status: 'committed', }); - const { result: purchased, isError: purchaseError } = await simulateCallTool(server, 'create_media_buy', { + const createArgs = { + idempotency_key: 'compact-purchase-once-0001', account, brand: account.brand, start_time: '2027-06-01T00:00:00Z', @@ -12830,9 +12867,372 @@ describe('proposal lifecycle', () => { signatory: 'compact-lifecycle-test', }, }), - }); + }; + for (const [suffix, opportunity] of [ + ['missing-id', { status: 'closed', close_reason: 'accepted_with_seller' }], + ['wrong-close', { + opportunity_id: 'opp-create-validation', + status: 'closed', + close_reason: 'not_pursued', + }], + ['extra-field', { opportunity_id: 'opp-create-validation', unexpected: true }], + ] as const) { + const malformed = await simulateCallTool(server, 'create_media_buy', { + ...createArgs, + idempotency_key: `compact-create-${suffix}-0001`, + opportunity, + }); + expect(malformed.isError).toBe(true); + expect(malformed.result).toMatchObject({ code: 'INVALID_REQUEST' }); + } + const { result: purchased, isError: purchaseError } = await simulateCallTool(server, 'create_media_buy', createArgs); expect(purchaseError, JSON.stringify(purchased)).toBeFalsy(); expect(purchased.media_buy_id).toEqual(expect.any(String)); + expect(purchased.proposal_id).toBe(committed.proposal_id); + expect(purchased).not.toHaveProperty('__executed'); + expect(purchased).not.toHaveProperty('__opportunity_update'); + + await runWithSessionContext(async () => { + const session = await getSession( + sessionKeyFromArgs({}, DEFAULT_CTX.mode, undefined, undefined, 'anonymous'), + ); + const stored = session.lastGetProductsContext?.proposals?.find( + proposal => proposal.proposal_id === committed.proposal_id, + ) as (Record | undefined); + expect(stored).toMatchObject({ + __executed: true, + __opportunity_update: { + opportunity_id: lifecycleOpportunity.opportunity_id, + status: 'closed', + close_reason: 'accepted_with_seller', + }, + }); + }); + + const exactRetry = await simulateCallTool(server, 'create_media_buy', createArgs); + expect(exactRetry.isError).toBeFalsy(); + expect(exactRetry.result).toMatchObject({ + media_buy_id: purchased.media_buy_id, + proposal_id: committed.proposal_id, + replayed: true, + }); + + const finalizedAgain = await simulateCallTool(server, 'finalize_proposals', { + proposal_ids: [committed.proposal_id], + }); + expect(finalizedAgain.isError).toBeFalsy(); + const finalizedAgainProposal = (finalizedAgain.result.proposals as Array>)[0]; + expect(finalizedAgainProposal).not.toHaveProperty('__executed'); + expect(finalizedAgainProposal).not.toHaveProperty('__opportunity_update'); + + const secondExecution = await simulateCallTool(server, 'create_media_buy', { + ...createArgs, + idempotency_key: 'compact-purchase-once-0002', + }); + expect(secondExecution.isError).toBe(true); + expect(secondExecution.result).toMatchObject({ code: 'INVALID_STATE' }); + + const declineAfterExecution = await simulateCallTool(server, 'decline_proposals', { + declines: [{ proposal_id: committed.proposal_id, reason: 'selected_alternative' }], + }); + expect(declineAfterExecution.isError).toBeFalsy(); + expect(declineAfterExecution.result).toMatchObject({ + results: [{ proposal_id: committed.proposal_id, outcome: 'unable' }], + }); + }); + + it('serializes competing executions of one compact proposal', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const { result: requested } = await simulateCallTool(server, 'request_proposals', { + brand: account.brand, + brief: 'social engagement display', + }); + const draft = (requested.proposals as Array>)[0]; + const { result: finalized } = await simulateCallTool(server, 'finalize_proposals', { + proposal_ids: [draft.proposal_id], + }); + const committed = (finalized.proposals as Array>)[0]; + const base = { + account, + brand: account.brand, + start_time: '2027-06-01T00:00:00Z', + end_time: '2027-07-01T00:00:00Z', + proposal_id: committed.proposal_id, + total_budget: { amount: 50000, currency: 'USD' }, + ...(committed.insertion_order && { + io_acceptance: { + io_id: (committed.insertion_order as Record).io_id, + accepted_at: new Date().toISOString(), + signatory: 'concurrent-execution-test', + }, + }), + }; + const attempts = await Promise.all([ + simulateCallTool(server, 'create_media_buy', { + ...base, + idempotency_key: `test-${randomUUID()}`, + }), + simulateCallTool(server, 'create_media_buy', { + ...base, + idempotency_key: `test-${randomUUID()}`, + }), + ]); + expect(attempts.filter(attempt => !attempt.isError)).toHaveLength(1); + expect(attempts.filter(attempt => attempt.isError)).toHaveLength(1); + expect(attempts.find(attempt => attempt.isError)?.result).toMatchObject({ code: 'INVALID_STATE' }); + }); + + it('serializes execution against terminal decline', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const { result: requested } = await simulateCallTool(server, 'request_proposals', { + brand: account.brand, + brief: 'social engagement display', + }); + const draft = (requested.proposals as Array>)[0]; + const { result: finalized } = await simulateCallTool(server, 'finalize_proposals', { + proposal_ids: [draft.proposal_id], + }); + const committed = (finalized.proposals as Array>)[0]; + const [create, decline] = await Promise.all([ + simulateCallTool(server, 'create_media_buy', { + idempotency_key: `test-${randomUUID()}`, + account, + brand: account.brand, + start_time: '2027-06-01T00:00:00Z', + end_time: '2027-07-01T00:00:00Z', + proposal_id: committed.proposal_id, + total_budget: { amount: 50000, currency: 'USD' }, + ...(committed.insertion_order && { + io_acceptance: { + io_id: (committed.insertion_order as Record).io_id, + accepted_at: new Date().toISOString(), + signatory: 'create-decline-race-test', + }, + }), + }), + simulateCallTool(server, 'decline_proposals', { + idempotency_key: `test-${randomUUID()}`, + declines: [{ proposal_id: committed.proposal_id, reason: 'timing' }], + }), + ]); + const declineOutcome = ((decline.result.results as Array> | undefined)?.[0]?.outcome); + const terminalSuccesses = Number(!create.isError) + Number(declineOutcome === 'declined'); + expect(terminalSuccesses).toBe(1); + if (create.isError) expect(create.result).toMatchObject({ code: 'INVALID_STATE' }); + else if (decline.isError) expect(decline.result).toMatchObject({ code: 'CONFLICT' }); + else expect(declineOutcome).toBe('unable'); + }); + + it('makes proposal decline terminal, semantically idempotent, and opportunity-aware', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const opportunity = { + opportunity_id: 'opp-proposal-decline-2027', + phase: 'active_sourcing', + intent: 'live_rfp', + }; + const { result: requested, isError: requestError } = await simulateCallTool(server, 'request_proposals', { + brand: account.brand, + brief: 'social engagement display', + opportunity, + }); + expect(requestError).toBeFalsy(); + const draft = (requested.proposals as Array>)[0]; + + const { result: refined, isError: refineError } = await simulateCallTool(server, 'refine_proposals', { + refinements: [{ + proposal_id: draft.proposal_id, + instructions: 'Prefer social inventory without changing the planning cycle.', + }], + }); + expect(refineError).toBeFalsy(); + const revision = ((refined.results as Array>)[0].proposal) as Record; + const compactSessionKey = sessionKeyFromArgs({}, DEFAULT_CTX.mode, undefined, undefined, 'anonymous'); + await runWithSessionContext(async () => { + const session = await getSession(compactSessionKey); + const storedRevision = session.lastGetProductsContext?.proposals?.find( + proposal => proposal.proposal_id === revision.proposal_id, + ) as (Record | undefined); + expect(storedRevision?.__opportunity_id).toBe(opportunity.opportunity_id); + }); + + const { result: finalized, isError: finalizeError } = await simulateCallTool(server, 'finalize_proposals', { + proposal_ids: [revision.proposal_id], + }); + expect(finalizeError).toBeFalsy(); + const committed = (finalized.proposals as Array>)[0]; + + const mismatchedOpportunity = await simulateCallTool(server, 'decline_proposals', { + declines: [{ proposal_id: committed.proposal_id, reason: 'inventory_fit' }], + opportunity: { + opportunity_id: 'opp-some-other-cycle', + status: 'closed', + close_reason: 'not_pursued', + }, + }); + expect(mismatchedOpportunity.isError).toBe(true); + expect(mismatchedOpportunity.result).toMatchObject({ + code: 'INVALID_REQUEST', + field: 'opportunity.opportunity_id', + }); + + const declineArgs = { + declines: [{ + proposal_id: committed.proposal_id, + reason: 'inventory_fit', + detail: 'The original inventory feedback.', + }], + opportunity: { + opportunity_id: opportunity.opportunity_id, + status: 'closed', + close_reason: 'not_pursued', + }, + }; + const firstDecline = await simulateCallTool(server, 'decline_proposals', declineArgs); + expect(firstDecline.isError).toBeFalsy(); + expect(firstDecline.result).toMatchObject({ + results: [{ proposal_id: committed.proposal_id, outcome: 'declined' }], + }); + + const repeatedDecline = await simulateCallTool(server, 'decline_proposals', { + ...declineArgs, + declines: [{ + proposal_id: committed.proposal_id, + reason: 'price', + detail: 'This later feedback must not overwrite the first record.', + }], + opportunity: { opportunity_id: opportunity.opportunity_id }, + }); + expect(repeatedDecline.isError).toBeFalsy(); + expect(repeatedDecline.result).toMatchObject({ + results: [{ proposal_id: committed.proposal_id, outcome: 'declined' }], + }); + + await runWithSessionContext(async () => { + const session = await getSession(compactSessionKey); + const stored = session.lastGetProductsContext?.proposals?.find( + proposal => proposal.proposal_id === committed.proposal_id, + ) as (Record | undefined); + expect(stored).toMatchObject({ + __declined: true, + __decline_reason: 'inventory_fit', + __decline_detail: 'The original inventory feedback.', + __opportunity_update: { + opportunity_id: opportunity.opportunity_id, + status: 'closed', + close_reason: 'not_pursued', + }, + }); + }); + + const explicitReopen = await simulateCallTool(server, 'decline_proposals', { + declines: [{ proposal_id: committed.proposal_id, reason: 'price' }], + opportunity: { opportunity_id: opportunity.opportunity_id, status: 'open' }, + }); + expect(explicitReopen.isError).toBeFalsy(); + await runWithSessionContext(async () => { + const session = await getSession(compactSessionKey); + const stored = session.lastGetProductsContext?.proposals?.find( + proposal => proposal.proposal_id === committed.proposal_id, + ) as (Record | undefined); + expect(stored?.__opportunity_update).toEqual({ + opportunity_id: opportunity.opportunity_id, + status: 'open', + }); + }); + + const finalizeAfterDecline = await simulateCallTool(server, 'finalize_proposals', { + proposal_ids: [committed.proposal_id], + }); + expect(finalizeAfterDecline.isError).toBe(true); + expect(finalizeAfterDecline.result).toMatchObject({ code: 'INVALID_STATE' }); + + const refineAfterDecline = await simulateCallTool(server, 'refine_proposals', { + refinements: [{ + proposal_id: committed.proposal_id, + instructions: 'Try a different allocation after terminal decline.', + }], + }); + expect(refineAfterDecline.isError).toBeFalsy(); + expect(refineAfterDecline.result).toMatchObject({ + results: [{ source_proposal_id: committed.proposal_id, outcome: 'unable' }], + }); + + const purchase = await simulateCallTool(server, 'create_media_buy', { + account, + brand: account.brand, + start_time: '2027-06-01T00:00:00Z', + end_time: '2027-07-01T00:00:00Z', + proposal_id: committed.proposal_id, + total_budget: { amount: 50000, currency: 'USD' }, + opportunity: { + opportunity_id: opportunity.opportunity_id, + status: 'closed', + close_reason: 'accepted_with_seller', + }, + }); + expect(purchase.isError).toBe(true); + expect(purchase.result).toMatchObject({ code: 'INVALID_STATE' }); + }); + + it('keeps opportunity updates atomic across partial decline outcomes', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const opportunity = { opportunity_id: 'opp-partial-decline-2027', status: 'open' }; + const { result: requested } = await simulateCallTool(server, 'request_proposals', { + brand: account.brand, + brief: 'social engagement display', + opportunity, + }); + const proposal = (requested.proposals as Array>)[0]; + + const decline = await simulateCallTool(server, 'decline_proposals', { + declines: [ + { proposal_id: proposal.proposal_id, reason: 'timing' }, + { proposal_id: 'proposal-not-visible-to-caller', reason: 'timing' }, + ], + opportunity: { + opportunity_id: opportunity.opportunity_id, + status: 'closed', + close_reason: 'timing_changed', + }, + }); + expect(decline.isError).toBeFalsy(); + expect(decline.result).toMatchObject({ + results: [ + { proposal_id: proposal.proposal_id, outcome: 'declined' }, + { proposal_id: 'proposal-not-visible-to-caller', outcome: 'unable' }, + ], + }); + + await runWithSessionContext(async () => { + const session = await getSession( + sessionKeyFromArgs({}, DEFAULT_CTX.mode, undefined, undefined, 'anonymous'), + ); + const stored = session.lastGetProductsContext?.proposals?.find( + candidate => candidate.proposal_id === proposal.proposal_id, + ) as (Record | undefined); + expect(stored?.__declined).toBe(true); + expect(stored).not.toHaveProperty('__opportunity_update'); + }); + }); + + it('rejects duplicate decline proposal IDs and cannot decline registry proposals', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const duplicate = await simulateCallTool(server, 'decline_proposals', { + declines: [ + { proposal_id: 'proposal-1', reason: 'price' }, + { proposal_id: 'proposal-1', reason: 'timing' }, + ], + }); + expect(duplicate.isError).toBe(true); + expect(duplicate.result).toMatchObject({ code: 'INVALID_REQUEST' }); + + const registry = await simulateCallTool(server, 'decline_proposals', { + declines: [{ proposal_id: 'proposal_1', reason: 'price' }], + }); + expect(registry.isError).toBeFalsy(); + expect(registry.result).toMatchObject({ + results: [{ proposal_id: 'proposal_1', outcome: 'unable' }], + }); }); it('omits partial-only notes from a fully revised proposal result', async () => { diff --git a/static/schemas/source/core/account-authorization.json b/static/schemas/source/core/account-authorization.json index 74024bf9e6..e1e5539673 100644 --- a/static/schemas/source/core/account-authorization.json +++ b/static/schemas/source/core/account-authorization.json @@ -44,7 +44,7 @@ }, "read_only": { "type": "boolean", - "description": "Convenience flag. When true, the caller is permitted only non-mutating tasks. Sellers MUST reject any mutation from a read-only caller with READ_ONLY_SCOPE. For the AdCP 3.2 product split, read-only permits list_products but rejects request_proposals, refine_proposals, and finalize_proposals; a legacy get_products call is permitted only when the seller can guarantee the selected arm is a synchronous side-effect-free read. Sellers MAY omit this field; omission is equivalent to `false`. Callers MUST NOT infer read-only from `allowed_tasks` alone — the seller MUST set this explicitly when it applies.", + "description": "Convenience flag. When true, the caller is permitted only non-mutating tasks. Sellers MUST reject any mutation from a read-only caller with READ_ONLY_SCOPE. For the AdCP 3.2 product split, read-only permits list_products but rejects request_proposals, refine_proposals, finalize_proposals, and decline_proposals; a legacy get_products call is permitted only when the seller can guarantee the selected arm is a synchronous side-effect-free read. Sellers MAY omit this field; omission is equivalent to `false`. Callers MUST NOT infer read-only from `allowed_tasks` alone — the seller MUST set this explicitly when it applies.", "default": false } }, diff --git a/static/schemas/source/core/async-response-data.json b/static/schemas/source/core/async-response-data.json index 1d8e13e95e..2e32cebe0e 100644 --- a/static/schemas/source/core/async-response-data.json +++ b/static/schemas/source/core/async-response-data.json @@ -44,6 +44,11 @@ "description": "Terminal response for finalize_proposals", "$ref": "/schemas/media-buy/finalize-proposals-response.json" }, + { + "title": "DeclineProposalsResponse", + "description": "Terminal response for decline_proposals", + "$ref": "/schemas/media-buy/decline-proposals-response.json" + }, { "title": "GetSignalsResponse", "description": "Response for completed or failed get_signals", diff --git a/static/schemas/source/core/opportunity-context.json b/static/schemas/source/core/opportunity-context.json new file mode 100644 index 0000000000..8dc3a3c933 --- /dev/null +++ b/static/schemas/source/core/opportunity-context.json @@ -0,0 +1,86 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/opportunity-context.json", + "title": "Opportunity Context", + "description": "Buyer-supplied context for one planning cycle across proposal request, decline, and media-buy creation. The opaque opportunity_id is stable within the seller and account scope; sellers associate it with proposals without treating it as proposal identity.", + "type": "object", + "properties": { + "opportunity_id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$", + "x-entity": "opportunity", + "description": "Opaque buyer-assigned identifier for this planning cycle, scoped to the seller and account." + }, + "phase": { + "type": "string", + "enum": ["exploratory", "planning", "active_sourcing"], + "description": "Current stage of buyer planning." + }, + "intent": { + "type": "string", + "enum": ["test", "speculative", "planning", "live_rfp"], + "description": "How seriously the buyer is evaluating supply in this cycle." + }, + "planning_horizon": { + "$ref": "/schemas/core/date-range.json", + "description": "Inclusive calendar range in which the buyer expects the campaign to run." + }, + "response_deadline": { + "type": "string", + "format": "date-time", + "description": "Deadline by which the buyer needs a seller response." + }, + "status": { + "type": "string", + "enum": ["open", "closed"], + "description": "Whether the planning cycle remains open. On calls after initial creation, omission means no status update and MUST NOT reopen or close the opportunity implicitly, except that successful create_media_buy proposal execution explicitly infers accepted closure." + }, + "close_reason": { + "type": "string", + "enum": [ + "accepted_with_seller", + "purchased_elsewhere", + "selected_alternative", + "not_pursued", + "budget_changed", + "timing_changed", + "other" + ], + "description": "Why the opportunity closed. Required when status is closed." + }, + "close_detail": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Optional non-sensitive context about closure. MUST NOT identify a competitor or disclose confidential clearing terms." + } + }, + "required": ["opportunity_id"], + "allOf": [ + { + "if": { + "properties": { "status": { "const": "closed" } }, + "required": ["status"] + }, + "then": { "required": ["close_reason"] }, + "else": { + "not": { + "anyOf": [ + { "required": ["close_reason"] }, + { "required": ["close_detail"] } + ] + } + } + }, + { + "if": { + "properties": { "close_reason": { "const": "other" } }, + "required": ["close_reason"] + }, + "then": { "required": ["close_detail"] } + } + ], + "additionalProperties": false +} diff --git a/static/schemas/source/core/proposal.json b/static/schemas/source/core/proposal.json index b9c8477205..1000f1cbcc 100644 --- a/static/schemas/source/core/proposal.json +++ b/static/schemas/source/core/proposal.json @@ -2,12 +2,13 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/proposal.json", "title": "Proposal", - "description": "A proposed media plan with fixed or seller-optimized budget allocation across products. Committed proposals execute through create_media_buy(proposal_id); draft proposals commit through finalize_proposals (or the legacy get_products finalize form during 3.x).", + "description": "A proposed media-plan snapshot with fixed or seller-optimized budget allocation across products. In the AdCP 3.2 split proposal lifecycle, refinement or changed commercial terms produce a new proposal_id while lifecycle and buyer-disposition state may progress on the same snapshot. Legacy get_products proposal behavior remains compatible throughout 3.x. Committed proposals execute through create_media_buy(proposal_id), drafts commit through finalize_proposals, and proposals declined through the split lifecycle cannot execute.", "type": "object", "properties": { "proposal_id": { "type": "string", - "description": "Unique identifier for this proposal. Used to finalize a draft proposal and to execute a committed proposal via create_media_buy.", + "description": "Unique identifier for this commercial snapshot. Used to refine or decline a proposal, finalize a draft, and execute a committed proposal via create_media_buy. For proposals issued by the AdCP 3.2 split tasks, a seller MUST issue a new ID whenever refinement changes terms.", + "x-entity": "proposal", "maxLength": 255 }, "name": { diff --git a/static/schemas/source/core/x-entity-types.json b/static/schemas/source/core/x-entity-types.json index 9e4c43663f..6a5c082869 100644 --- a/static/schemas/source/core/x-entity-types.json +++ b/static/schemas/source/core/x-entity-types.json @@ -14,6 +14,7 @@ "package", "product", "proposal", + "opportunity", "placement", "product_pricing_option", "vendor_pricing_option", @@ -57,7 +58,8 @@ "media_buy": "A media buy / campaign. `media_buy_id` across media-buy/* schemas.", "package": "A line item within a media buy. `package_id` across media-buy/* schemas.", "product": "A seller inventory product. `product_id` across media-buy/* and core/product.json.", - "proposal": "A seller-issued draft or committed media-plan proposal. `proposal_id` is returned by request_proposals or refine_proposals and consumed by refine_proposals, finalize_proposals, and create_media_buy. Scoped to the issuing seller and authenticated principal.", + "proposal": "A seller-issued immutable draft or committed media-plan snapshot. `proposal_id` is returned by request_proposals or refine_proposals and consumed by refine_proposals, finalize_proposals, decline_proposals, and create_media_buy. Scoped to the issuing seller and authenticated principal.", + "opportunity": "A buyer-assigned planning cycle spanning proposal request, decline, and media-buy creation. `opportunity_id` is scoped to the seller and account and is not proposal identity.", "placement": "A public ad placement within a publisher namespace. Identified by the composite of `publisher_domain` plus `placement_id` in core/placement-ref.json and core/placement.json; legacy single-publisher contexts may omit publisher_domain but the identifier is still placement-scoped, not product-scoped.", "product_pricing_option": "A pricing tier on a seller's inventory product (CPM / CPC / CPCV / etc). `pricing_option_id` inside `core/package.json` and `media-buy/package-request.json`. Scoped to the seller's product rate card — not interchangeable with `vendor_pricing_option`.", "vendor_pricing_option": "A pricing tier offered by a vendor agent (rights agent, signals agent, creative agent, governance agent) for its own services. `pricing_option_id` via `core/vendor-pricing-option.json`, also surfaced in `brand/acquire-rights-*`, `signals/activate-signal-request`, `media-buy/build-creative-response`, and `creative/get-creative-features-response`. Scoped to the issuing agent; not interchangeable with `product_pricing_option`.", diff --git a/static/schemas/source/enums/proposal-decline-reason.json b/static/schemas/source/enums/proposal-decline-reason.json new file mode 100644 index 0000000000..10cd28ab96 --- /dev/null +++ b/static/schemas/source/enums/proposal-decline-reason.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/enums/proposal-decline-reason.json", + "title": "Proposal Decline Reason", + "description": "Machine-readable reason a buyer is no longer pursuing a proposal. Sellers use this only for aggregate feedback; buyers MUST NOT disclose competitor identity or confidential terms.", + "type": "string", + "enum": [ + "price", + "inventory_fit", + "audience_fit", + "creative_unsupported", + "measurement_unsupported", + "policy", + "timing", + "budget_changed", + "selected_alternative", + "other" + ], + "enumDescriptions": { + "price": "Price, rate, or cost efficiency did not meet the buyer's requirement.", + "inventory_fit": "Inventory, placements, format mix, availability, or forecast did not fit the brief.", + "audience_fit": "The proposal did not sufficiently match the required audience or targeting.", + "creative_unsupported": "The seller could not support the required creative format, asset, or production workflow.", + "measurement_unsupported": "The seller could not support required measurement, reporting, attribution, or verification.", + "policy": "The proposal was incompatible with buyer, seller, governance, legal, or brand-suitability policy.", + "timing": "The schedule, expiry, approval timing, or delivery window did not fit.", + "budget_changed": "The buyer's budget, scope, or campaign plan changed after evaluation.", + "selected_alternative": "The buyer selected another approach or supply source.", + "other": "Another reason; detail is required." + } +} diff --git a/static/schemas/source/enums/task-type.json b/static/schemas/source/enums/task-type.json index fbef22a2eb..9fecf3d7c7 100644 --- a/static/schemas/source/enums/task-type.json +++ b/static/schemas/source/enums/task-type.json @@ -16,6 +16,7 @@ "request_proposals", "refine_proposals", "finalize_proposals", + "decline_proposals", "get_signals", "create_property_list", "update_property_list", @@ -48,6 +49,7 @@ "request_proposals": "Media-buy domain: Request seller-authored proposals from a campaign brief", "refine_proposals": "Media-buy domain: Create revised proposal drafts from existing proposals", "finalize_proposals": "Media-buy domain: Atomically commit proposal drafts to firm terms", + "decline_proposals": "Media-buy domain: Terminally decline immutable proposals and prevent later execution", "get_signals": "Signals domain: Discover available audience signals based on natural language description", "create_property_list": "Property domain: Create a new property list with filters and brand reference", "update_property_list": "Property domain: Update an existing property list", diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index 1e5286ff82..bb3cd9ee73 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -254,6 +254,10 @@ "$ref": "/schemas/core/date-range.json", "description": "Date range with inclusive start and end calendar dates" }, + "opportunity-context": { + "$ref": "/schemas/core/opportunity-context.json", + "description": "Buyer planning-cycle context shared across proposal request, decline, and purchase" + }, "datetime-range": { "$ref": "/schemas/core/datetime-range.json", "description": "Datetime range with inclusive start and end timestamps" @@ -821,6 +825,10 @@ "$ref": "/schemas/enums/proposal-status.json", "description": "Lifecycle status of a proposal (draft or committed)" }, + "proposal-decline-reason": { + "$ref": "/schemas/enums/proposal-decline-reason.json", + "description": "Machine-readable terminal proposal feedback" + }, "media-buy-status": { "$ref": "/schemas/enums/media-buy-status.json", "description": "Status of a media buy" @@ -1311,6 +1319,10 @@ "$ref": "/schemas/media-buy/proposal-refinement.json", "description": "One immutable proposal revision request" }, + "proposal-decline": { + "$ref": "/schemas/media-buy/proposal-decline.json", + "description": "One terminal decline of an immutable proposal" + }, "get-products-rejected": { "$ref": "/schemas/media-buy/get-products-rejected.json", "description": "Terminal business rejection arm for a well-formed get_products brief or refinement" @@ -1329,7 +1341,7 @@ "request": { "$ref": "/schemas/media-buy/get-products-request.json", "deprecated": true, - "description": "AdCP 3.x compatibility request. New 3.2 callers use list_products, request_proposals, refine_proposals, or finalize_proposals." + "description": "AdCP 3.x compatibility request. New 3.2 callers use list_products, request_proposals, refine_proposals, finalize_proposals, or decline_proposals." }, "response": { "$ref": "/schemas/media-buy/get-products-response.json", @@ -1377,6 +1389,16 @@ "description": "Response payload for finalize_proposals" } }, + "decline-proposals": { + "request": { + "$ref": "/schemas/media-buy/decline-proposals-request.json", + "description": "Request parameters for terminally declining one or more proposals" + }, + "response": { + "$ref": "/schemas/media-buy/decline-proposals-response.json", + "description": "Ordered decline results for decline_proposals" + } + }, "list-creative-formats": { "request": { "$ref": "/schemas/media-buy/list-creative-formats-request.json", diff --git a/static/schemas/source/media-buy/create-media-buy-request.json b/static/schemas/source/media-buy/create-media-buy-request.json index 8a04f869ab..b43caa67c8 100644 --- a/static/schemas/source/media-buy/create-media-buy-request.json +++ b/static/schemas/source/media-buy/create-media-buy-request.json @@ -8,6 +8,10 @@ { "$ref": "/schemas/core/version-envelope.json" }, + { + "if": { "required": ["opportunity"] }, + "then": { "required": ["proposal_id"] } + }, { "if": { "required": ["bidding"] }, "then": { @@ -72,7 +76,24 @@ }, "proposal_id": { "type": "string", - "description": "ID of a committed proposal from finalize_proposals to execute. With total_budget, the publisher creates packages using the proposal's fixed percentages or seller-optimized constraints. Alternative to providing packages. If the referenced proposal has proposal_status: 'draft', the seller MUST reject with PROPOSAL_NOT_COMMITTED; the buyer calls finalize_proposals first. The legacy get_products finalize form remains supported throughout AdCP 3.x." + "x-entity": "proposal", + "description": "ID of the exact committed proposal snapshot to execute. With total_budget, the publisher creates packages using the proposal's fixed percentages or seller-optimized constraints. Alternative to providing packages. For IDs issued by the AdCP 3.2 split proposal tasks, sellers MUST reject draft proposals with PROPOSAL_NOT_COMMITTED and declined or previously executed proposals with INVALID_STATE; exact retries with the original idempotency key still replay historical success. Changed commercial terms in that split lifecycle are issued under a new proposal_id, so no separate proposal version is required. Legacy get_products proposal behavior remains supported throughout AdCP 3.x." + }, + "opportunity": { + "allOf": [ + { "$ref": "/schemas/core/opportunity-context.json" }, + { + "properties": { "status": { "const": "closed" } }, + "if": { + "properties": { "status": { "const": "closed" } }, + "required": ["status"] + }, + "then": { + "properties": { "close_reason": { "const": "accepted_with_seller" } } + } + } + ], + "description": "Optional planning-cycle closure. Sellers infer successful proposal execution as closed with close_reason accepted_with_seller when status is omitted; when status is present it MUST be closed with that reason. If the proposal was issued under an opportunity_id, a supplied ID MUST match it." }, "total_budget": { "type": "object", diff --git a/static/schemas/source/media-buy/create-media-buy-response.json b/static/schemas/source/media-buy/create-media-buy-response.json index 47eb3fac04..2154098d11 100644 --- a/static/schemas/source/media-buy/create-media-buy-response.json +++ b/static/schemas/source/media-buy/create-media-buy-response.json @@ -18,6 +18,12 @@ "description": "Success response - media buy created successfully", "type": "object", "properties": { + "proposal_id": { + "type": "string", + "minLength": 1, + "x-entity": "proposal", + "description": "The immutable committed proposal executed by this media buy, echoed when proposal_id was supplied in the request." + }, "media_buy_id": { "type": "string", "description": "Seller's unique identifier for the created media buy", diff --git a/static/schemas/source/media-buy/decline-proposals-request.json b/static/schemas/source/media-buy/decline-proposals-request.json new file mode 100644 index 0000000000..496e365d68 --- /dev/null +++ b/static/schemas/source/media-buy/decline-proposals-request.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/decline-proposals-request.json", + "title": "Decline Proposals Request", + "description": "Terminally decline one or more immutable proposals. Repeating an already-applied decline with a new idempotency key succeeds with outcome declined and preserves the first recorded reason and detail; the proposal cannot later be executed.", + "type": "object", + "x-mutates-state": true, + "x-operation-family": "decline_proposals", + "x-added-in": "3.2.0", + "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, + "adcp_major_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_major_version" }, + "context_id": { "type": "string", "minLength": 1 }, + "context": { + "allOf": [{ "$ref": "/schemas/core/context.json" }], + "x-adcp-schema-uri": "/schemas/core/context.json" + }, + "governance_context": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "push_notification_config": { + "allOf": [{ "$ref": "/schemas/core/push-notification-config.json" }], + "x-adcp-schema-uri": "/schemas/core/push-notification-config.json" + }, + "idempotency_key": { + "type": "string", + "description": "Client-generated key required for retry-safe proposal decline.", + "minLength": 16, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{16,255}$" + }, + "declines": { + "type": "array", + "description": "Proposal declines to apply. proposal_id is the semantic uniqueness key and values MUST be unique even when two entries otherwise differ; implementations enforce this rule because JSON Schema uniqueItems only compares whole objects. Results preserve request order.", + "items": { "$ref": "/schemas/media-buy/proposal-decline.json" }, + "minItems": 1, + "maxItems": 25, + "uniqueItems": true + }, + "opportunity": { + "$ref": "/schemas/core/opportunity-context.json", + "description": "Optional planning-cycle update. Every named proposal MUST belong to this opportunity_id. Sellers apply the update only when every result is declined; if any result is unable, the opportunity remains unchanged. Use status closed when these declines end the broader opportunity." + } + }, + "required": ["idempotency_key", "declines"], + "additionalProperties": false +} diff --git a/static/schemas/source/media-buy/decline-proposals-response.json b/static/schemas/source/media-buy/decline-proposals-response.json new file mode 100644 index 0000000000..3929c62ef4 --- /dev/null +++ b/static/schemas/source/media-buy/decline-proposals-response.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/decline-proposals-response.json", + "title": "Decline Proposals Response", + "description": "One ordered terminal result for each requested proposal decline.", + "type": "object", + "properties": { + "results": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "discriminator": { "propertyName": "outcome" }, + "properties": { + "proposal_id": { + "type": "string", + "minLength": 1, + "x-entity": "proposal" + }, + "outcome": { + "type": "string", + "enum": ["declined", "unable"] + }, + "reason": { + "type": "string", + "minLength": 1, + "description": "Why the seller could not apply a decline. Present only for outcome unable." + } + }, + "required": ["proposal_id", "outcome"], + "oneOf": [ + { + "properties": { "outcome": { "const": "declined" } }, + "required": ["outcome"], + "not": { "required": ["reason"] } + }, + { + "properties": { "outcome": { "const": "unable" } }, + "required": ["outcome", "reason"] + } + ], + "additionalProperties": false + } + } + }, + "required": ["results"], + "additionalProperties": true +} diff --git a/static/schemas/source/media-buy/get-products-request.json b/static/schemas/source/media-buy/get-products-request.json index ca15495d8d..51c9592284 100644 --- a/static/schemas/source/media-buy/get-products-request.json +++ b/static/schemas/source/media-buy/get-products-request.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/get-products-request.json", "title": "Get Products Request", - "description": "AdCP 3.x compatibility request for discovering, refining, or finalizing advertising products and proposals. AdCP 3.2 introduces the narrower list_products, request_proposals, refine_proposals, and finalize_proposals tasks; new callers SHOULD use those tasks. Existing get_products payloads remain valid throughout 3.x.", + "description": "AdCP 3.x compatibility request for discovering and changing advertising products and proposals. AdCP 3.2 introduces the narrower list_products, request_proposals, refine_proposals, finalize_proposals, and decline_proposals tasks; new callers SHOULD use those tasks. Existing get_products payloads remain valid throughout 3.x.", "type": "object", "allOf": [ { @@ -144,12 +144,12 @@ "finalize" ], "default": "include", - "description": "'include' (default): return this proposal with updated allocations and pricing. 'omit': exclude this proposal from the response. 'finalize': request firm pricing and inventory hold — transitions a draft proposal to committed with an expires_at hold window. May trigger seller-side approval (HITL). The buyer should not set a time_budget for finalize requests — they represent a commitment to wait for the result. Optional — when omitted, the seller treats the entry as action: 'include'.\n\nFinalize is exclusive within the parent `refine[]` array: see the array-level description for the finalize-exclusivity rule (mixing finalize with non-finalize entries is rejected) and multi-finalize atomicity contract." + "description": "'include' (default): return this proposal with updated allocations and pricing. 'omit': exclude this proposal from the response. 'finalize': request firm pricing and inventory hold. New callers use the dedicated refine_proposals and finalize_proposals tasks; terminal feedback is available only through decline_proposals.\n\nFinalize is exclusive within the parent `refine[]` array: see the array-level description for the finalize-exclusivity rule (mixing finalize with non-finalize entries is rejected) and multi-finalize atomicity contract." }, "ask": { "type": "string", "minLength": 1, - "description": "What the buyer is asking for on this proposal (e.g., 'shift more budget toward video', 'reduce total by 10%'). Ignored when action is 'omit'." + "description": "What the buyer is asking for on this proposal (e.g., 'shift more budget toward video', 'reduce total by 10%'). Ignored when action is omit." } }, "required": [ diff --git a/static/schemas/source/media-buy/proposal-decline.json b/static/schemas/source/media-buy/proposal-decline.json new file mode 100644 index 0000000000..fc8616d7c5 --- /dev/null +++ b/static/schemas/source/media-buy/proposal-decline.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/proposal-decline.json", + "title": "Proposal Decline", + "description": "Terminal buyer feedback for one immutable proposal snapshot.", + "type": "object", + "properties": { + "proposal_id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "x-entity": "proposal", + "description": "Immutable proposal ID returned by request_proposals or refine_proposals." + }, + "reason": { "$ref": "/schemas/enums/proposal-decline-reason.json" }, + "detail": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Optional short, non-identifying explanation. MUST NOT identify a competitor or disclose sensitive campaign information." + } + }, + "required": ["proposal_id", "reason"], + "allOf": [ + { + "if": { + "properties": { "reason": { "const": "other" } }, + "required": ["reason"] + }, + "then": { "required": ["detail"] } + } + ], + "additionalProperties": false +} diff --git a/static/schemas/source/media-buy/request-proposals-request.json b/static/schemas/source/media-buy/request-proposals-request.json index 583cc37e7c..94114a8e2c 100644 --- a/static/schemas/source/media-buy/request-proposals-request.json +++ b/static/schemas/source/media-buy/request-proposals-request.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/request-proposals-request.json", "title": "Request Proposals Request", - "description": "Ask a seller to create one or more actionable media-plan proposals from a brief. Proposal IDs returned here connect refinement, finalization, and purchase.", + "description": "Ask a seller to create one or more actionable media-plan proposals from a brief. Each returned proposal_id identifies one immutable commercial snapshot and connects refinement, finalization, decline, and purchase; changed commercial terms require a new proposal_id.", "type": "object", "x-mutates-state": true, "x-operation-family": "request_proposals", @@ -39,7 +39,18 @@ "minLength": 1, "description": "Campaign goal, strategy, and requirements that are not represented in structured criteria." }, - "criteria": { "$ref": "/schemas/media-buy/product-discovery-criteria.json" } + "criteria": { "$ref": "/schemas/media-buy/product-discovery-criteria.json" }, + "opportunity": { + "allOf": [ + { "$ref": "/schemas/core/opportunity-context.json" }, + { + "properties": { + "status": { "const": "open" } + } + } + ], + "description": "Optional planning-cycle context that the seller associates with every proposal created by this request." + } }, "required": ["idempotency_key", "brand", "brief"], "additionalProperties": false diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index bc149bc3ff..5958d5e892 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -661,7 +661,7 @@ "description": "Product-discovery operation names this seller supports. Added in AdCP 3.2 as compact, task-specific contracts that form the 4.0 lifecycle foundation. Sellers may advertise any supported subset while retaining get_products throughout 3.x. Absence means the caller uses the legacy facade. Each stateful split task has its own idempotency identity; callers MUST retry with the same tool name.", "items": { "type": "string", - "enum": ["get_products", "list_products", "request_proposals", "refine_proposals", "finalize_proposals"] + "enum": ["get_products", "list_products", "request_proposals", "refine_proposals", "finalize_proposals", "decline_proposals"] }, "minItems": 1, "uniqueItems": true diff --git a/tests/composed-schema-validation.test.cjs b/tests/composed-schema-validation.test.cjs index 275de7e88b..f1510a07cb 100644 --- a/tests/composed-schema-validation.test.cjs +++ b/tests/composed-schema-validation.test.cjs @@ -3098,6 +3098,136 @@ async function runTests() { }, 'finalize_proposals rejects duplicate proposal IDs' ); + await testSchemaValidation( + '/schemas/media-buy/request-proposals-request.json', + { + idempotency_key: 'request-proposals-opp-0001', + brand: { domain: 'buyer.example' }, + brief: 'Reach streaming audio listeners in Rome', + opportunity: { + opportunity_id: 'opp-rome-audio-2027', + phase: 'active_sourcing', + intent: 'live_rfp', + response_deadline: '2027-01-15T17:00:00Z' + } + }, + 'request_proposals accepts reusable opportunity context' + ); + await testSchemaRejection( + '/schemas/media-buy/request-proposals-request.json', + { + idempotency_key: 'request-proposals-opp-0002', + brand: { domain: 'buyer.example' }, + brief: 'Reach streaming audio listeners in Rome', + opportunity: { + opportunity_id: 'opp-rome-audio-closed', + status: 'closed', + close_reason: 'not_pursued' + } + }, + 'request_proposals rejects a closed opportunity' + ); + await testSchemaValidation( + '/schemas/media-buy/decline-proposals-request.json', + { + idempotency_key: 'decline-proposals-0001', + declines: [{ proposal_id: 'proposal-1', reason: 'inventory_fit' }], + opportunity: { + opportunity_id: 'opp-rome-audio-2027', + status: 'closed', + close_reason: 'not_pursued' + } + }, + 'decline_proposals accepts terminal proposal and opportunity feedback' + ); + await testSchemaRejection( + '/schemas/media-buy/decline-proposals-request.json', + { + idempotency_key: 'decline-proposals-0002', + declines: [{ proposal_id: 'proposal-1', reason: 'other' }] + }, + 'decline_proposals requires detail for an other reason' + ); + await testSchemaRejection( + '/schemas/media-buy/decline-proposals-request.json', + { + idempotency_key: 'decline-proposals-0003', + declines: [{ proposal_id: 'proposal-1', reason: 'price' }], + opportunity: { + opportunity_id: 'opp-rome-audio-2027', + close_reason: 'not_pursued' + } + }, + 'opportunity close fields require closed status' + ); + await testSchemaValidation( + '/schemas/media-buy/decline-proposals-response.json', + { + results: [ + { proposal_id: 'proposal-1', outcome: 'declined' }, + { proposal_id: 'proposal-2', outcome: 'unable', reason: 'Proposal not found.' } + ] + }, + 'decline_proposals returns one explicit outcome per proposal' + ); + const proposalExecution = { + idempotency_key: 'create-proposal-opportunity-0001', + account: { account_id: 'account-opportunity-test' }, + brand: { domain: 'buyer.example' }, + proposal_id: 'proposal-1', + total_budget: { amount: 50000, currency: 'USD' }, + start_time: 'asap', + end_time: '2027-07-01T00:00:00Z' + }; + await testSchemaValidation( + '/schemas/media-buy/create-media-buy-request.json', + { + ...proposalExecution, + opportunity: { opportunity_id: 'opp-rome-audio-2027' } + }, + 'create_media_buy permits status omission for inferred close-won' + ); + await testSchemaValidation( + '/schemas/media-buy/create-media-buy-request.json', + { + ...proposalExecution, + opportunity: { + opportunity_id: 'opp-rome-audio-2027', + status: 'closed', + close_reason: 'accepted_with_seller' + } + }, + 'create_media_buy accepts explicit accepted-with-seller closure' + ); + await testSchemaRejection( + '/schemas/media-buy/create-media-buy-request.json', + { + ...proposalExecution, + opportunity: { + opportunity_id: 'opp-rome-audio-2027', + status: 'closed', + close_reason: 'not_pursued' + } + }, + 'create_media_buy rejects non-winning explicit opportunity closure' + ); + await testSchemaRejection( + '/schemas/media-buy/create-media-buy-request.json', + { + idempotency_key: 'create-package-opportunity-0001', + account: { account_id: 'account-opportunity-test' }, + brand: { domain: 'buyer.example' }, + packages: [{ + product_id: 'display-standard', + pricing_option_id: 'fixed-cpm', + budget: 50000 + }], + start_time: 'asap', + end_time: '2027-07-01T00:00:00Z', + opportunity: { opportunity_id: 'opp-package-mode' } + }, + 'create_media_buy restricts opportunity closure to proposal mode' + ); const splitCapabilityBase = { status: 'completed', adcp: { diff --git a/tests/docs-nav-validation.test.cjs b/tests/docs-nav-validation.test.cjs index 464c27017b..f1e76a6a73 100644 --- a/tests/docs-nav-validation.test.cjs +++ b/tests/docs-nav-validation.test.cjs @@ -251,6 +251,7 @@ test('temporary snapshot redirects cover every available live page', () => { 'docs/media-buy/task-reference/request_proposals', 'docs/media-buy/task-reference/refine_proposals', 'docs/media-buy/task-reference/finalize_proposals', + 'docs/media-buy/task-reference/decline_proposals', 'docs/creative/channels/radio', 'docs/brand-protocol/tasks/search_brands', ]; From 6184a0ad6e65868b9f9821f4f0139f7247803d8c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 11 Aug 2026 10:29:18 +0200 Subject: [PATCH 26/49] docs(media-buy): pin decline proposal schema link --- docs/media-buy/task-reference/create_media_buy.mdx | 4 ++-- docs/media-buy/task-reference/decline_proposals.mdx | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/media-buy/task-reference/create_media_buy.mdx b/docs/media-buy/task-reference/create_media_buy.mdx index 3ca472ecfe..1d6610e880 100644 --- a/docs/media-buy/task-reference/create_media_buy.mdx +++ b/docs/media-buy/task-reference/create_media_buy.mdx @@ -141,8 +141,8 @@ npx @adcp/sdk@latest \ | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `account` | [account-ref](/docs/building/by-layer/L2/accounts-and-agents#account-references) | Yes | Account reference. Pass `{ "account_id": "..." }` or `{ "brand": {...}, "operator": "..." }` if the seller supports implicit resolution. Required for billing and policy evaluation. | -| `proposal_id` | string | No* | ID of the exact committed proposal snapshot from [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals). For IDs from the 3.2 split lifecycle, draft proposals fail with [`PROPOSAL_NOT_COMMITTED`](/docs/building/verification/compliance-catalog#error-code-proposal-not-committed), while declined or already executed proposals fail with `INVALID_STATE`. Exact retries reuse the original idempotency key and replay success. Legacy `get_products` proposals retain 3.x behavior. | -| `opportunity` | OpportunityContext | No | Proposal-mode planning-cycle closure shared with `request_proposals` and `decline_proposals`; requires `proposal_id`. Omitting status is the create-specific signal to infer closed with `accepted_with_seller`; if status is sent, it must explicitly carry that closure. | +| `proposal_id` | string | No* | ID of the exact committed proposal snapshot from [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals). For IDs from the 3.2 split lifecycle, draft proposals fail with [`PROPOSAL_NOT_COMMITTED`](/docs/building/verification/compliance-catalog#error-code-proposal-not-committed), while declined or already executed proposals fail with [`INVALID_STATE`](/docs/building/verification/compliance-catalog#error-code-invalid-state). Exact retries reuse the original idempotency key and replay success. Legacy [`get_products`](/docs/media-buy/task-reference/get_products) proposals retain 3.x behavior. | +| `opportunity` | OpportunityContext | No | Proposal-mode planning-cycle closure shared with [`request_proposals`](/docs/media-buy/task-reference/request_proposals) and [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals); requires `proposal_id`. Omitting status is the create-specific signal to infer closed with `accepted_with_seller`; if status is sent, it must explicitly carry that closure. | | `total_budget` | TotalBudget | No* | Hard aggregate lifetime budget. Its currency is the single media-buy denomination for package constraints and canonical bidding. Required for proposals and seller-optimized explicit packages. Optional in fixed explicit-package mode; when supplied there, it must equal the sum of package budgets. | | `budget_allocation` | BudgetAllocation | No | Cross-package allocation mode. Omit for fixed allocation. Use `seller_optimized` with media-buy optimization goals to delegate allocation to the seller. Must be omitted when executing a proposal because the committed proposal supplies it. | | `packages` | Package[] | No* | Array of package configurations (see below). Required when not using proposal_id. | diff --git a/docs/media-buy/task-reference/decline_proposals.mdx b/docs/media-buy/task-reference/decline_proposals.mdx index 8e182008e9..0ec11db21e 100644 --- a/docs/media-buy/task-reference/decline_proposals.mdx +++ b/docs/media-buy/task-reference/decline_proposals.mdx @@ -5,10 +5,9 @@ description: "Terminally decline one or more immutable proposals." testable: true --- -`decline_proposals` records that the buyer evaluated one or more proposals and will not pursue them. The operation is terminal: a seller must reject later `finalize_proposals` or new logical `create_media_buy` attempts for a declined proposal with `INVALID_STATE`. Exact retries of an earlier successful call still follow the shared idempotency replay contract. A proposal that was already executed cannot subsequently be declined and returns `unable`. +`decline_proposals` records that the buyer evaluated one or more proposals and will not pursue them. The operation is terminal: a seller must reject later [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals) or new logical [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) attempts for a declined proposal with [`INVALID_STATE`](/docs/building/verification/compliance-catalog#error-code-invalid-state). Exact retries of an earlier successful call still follow the shared idempotency replay contract. A proposal that was already executed cannot subsequently be declined and returns `unable`. -{/* Unreleased 3.2 contract: switch to the semver path when 3.2 is published. */} -**Request schema:** [`/schemas/latest/media-buy/decline-proposals-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/decline-proposals-request.json) +**Request schema:** [`/schemas/v3/media-buy/decline-proposals-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/decline-proposals-request.json) ```json { From 4539e734eb25a329304097ea5bbf58815f654cbc Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 11 Aug 2026 10:43:29 +0200 Subject: [PATCH 27/49] test(media-buy): classify proposal decline scope --- scripts/lint-storyboard-scoping.cjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/lint-storyboard-scoping.cjs b/scripts/lint-storyboard-scoping.cjs index aec7f76922..74eeefee48 100644 --- a/scripts/lint-storyboard-scoping.cjs +++ b/scripts/lint-storyboard-scoping.cjs @@ -50,6 +50,7 @@ const TENANT_SCOPED_TASKS = new Set([ 'request_proposals', 'refine_proposals', 'finalize_proposals', + 'decline_proposals', 'get_signals', 'activate_signal', 'sync_audiences', From bd6cf6e12f00d081a5a8a8e87b407b988c23c98f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 11 Aug 2026 11:51:32 +0200 Subject: [PATCH 28/49] refactor(media-buy): simplify proposal lifecycle --- .changeset/secure-get-products-idempotency.md | 2 +- docs.json | 1 - docs/accounts/overview.mdx | 2 +- docs/building/by-layer/L0/schemas.mdx | 26 +++ docs/building/by-layer/L1/security.mdx | 8 +- docs/building/by-layer/L3/error-handling.mdx | 6 +- .../product-discovery/media-products.mdx | 2 +- .../product-discovery/refinement.mdx | 2 +- docs/media-buy/specification.mdx | 2 +- .../task-reference/create_media_buy.mdx | 10 +- .../task-reference/decline_proposals.mdx | 2 +- .../task-reference/finalize_proposals.mdx | 21 -- .../media-buy/task-reference/get_products.mdx | 4 +- docs/media-buy/task-reference/index.mdx | 5 +- .../task-reference/refine_proposals.mdx | 6 +- .../task-reference/request_proposals.mdx | 6 +- docs/protocol/get_adcp_capabilities.mdx | 6 +- docs/reference/release-notes.mdx | 8 +- docs/snippets/compliance-error-codes.mdx | 18 +- scripts/build-schemas.cjs | 37 +++- scripts/lint-storyboard-scoping.cjs | 1 - scripts/mcp-schema-projection.cjs | 58 +++++- server/src/training-agent/idempotency.ts | 1 - server/src/training-agent/task-handlers.ts | 197 +++++++----------- server/src/training-agent/tenants/router.ts | 1 - .../tenants/tenant-smoke.test.ts | 1 - .../training-agent/tenants/tool-catalog.ts | 2 - server/src/training-agent/webhooks.ts | 4 +- .../training-agent-tool-catalog-drift.test.ts | 1 - .../training-agent-webhooks.test.ts | 21 +- server/tests/unit/idempotency.test.ts | 2 +- .../product-discovery-schema-parity.test.ts | 1 - .../training-agent-webhook-contract.test.ts | 2 - server/tests/unit/training-agent.test.ts | 138 ++---------- .../source/core/account-authorization.json | 2 +- .../source/core/async-response-data.json | 5 - static/schemas/source/core/proposal.json | 8 +- .../schemas/source/core/x-entity-types.json | 2 +- .../list-creative-formats-request.json | 1 + static/schemas/source/enums/error-code.json | 12 +- .../schemas/source/enums/proposal-status.json | 4 +- static/schemas/source/enums/task-type.json | 4 +- static/schemas/source/index.json | 12 +- .../media-buy/create-media-buy-request.json | 2 +- .../media-buy/finalize-proposals-request.json | 41 ---- .../finalize-proposals-response.json | 34 --- .../media-buy/get-products-request.json | 4 +- .../list-creative-formats-request.json | 1 + .../source/media-buy/proposal-refinement.json | 2 +- .../media-buy/refine-proposals-request.json | 2 +- .../media-buy/refine-proposals-response.json | 2 +- .../media-buy/request-proposals-request.json | 2 +- .../media-buy/request-proposals-response.json | 4 +- .../get-adcp-capabilities-response.json | 32 +-- tests/composed-schema-validation.test.cjs | 70 +------ tests/docs-nav-validation.test.cjs | 1 - tests/mcp-schema-projection.test.cjs | 71 +++++++ 57 files changed, 368 insertions(+), 554 deletions(-) delete mode 100644 docs/media-buy/task-reference/finalize_proposals.mdx delete mode 100644 static/schemas/source/media-buy/finalize-proposals-request.json delete mode 100644 static/schemas/source/media-buy/finalize-proposals-response.json diff --git a/.changeset/secure-get-products-idempotency.md b/.changeset/secure-get-products-idempotency.md index b5579edafb..36723ff898 100644 --- a/.changeset/secure-get-products-idempotency.md +++ b/.changeset/secure-get-products-idempotency.md @@ -2,4 +2,4 @@ "adcontextprotocol": minor --- -Add the compact AdCP 3.2 product lifecycle: `list_products`, `request_proposals`, `refine_proposals`, `finalize_proposals`, and `decline_proposals`. The task-specific contracts separate offer discovery from proposal creation, immutable refinement, atomic commitment, terminal decline, and purchase while retaining `get_products` as a key-optional compatibility facade throughout 3.x. A shared opportunity reference connects planning-cycle context without duplicating proposal version identity. +Add the compact AdCP 3.2 product lifecycle: `list_products`, `request_proposals`, `refine_proposals`, and `decline_proposals`. The task-specific contracts separate offer discovery from executable proposal creation, immutable refinement, terminal decline, and purchase while retaining `get_products` as a key-optional compatibility facade throughout 3.x. A shared opportunity reference connects planning-cycle context without duplicating proposal version identity. Publish an MCP production profile that filters out compliance-only and deprecated tools and removes presentation annotations without changing validation semantics. diff --git a/docs.json b/docs.json index 4ed10064a4..e88ef1884f 100644 --- a/docs.json +++ b/docs.json @@ -296,7 +296,6 @@ "docs/media-buy/task-reference/list_products", "docs/media-buy/task-reference/request_proposals", "docs/media-buy/task-reference/refine_proposals", - "docs/media-buy/task-reference/finalize_proposals", "docs/media-buy/task-reference/decline_proposals", "docs/media-buy/task-reference/create_media_buy", "docs/media-buy/task-reference/sync_catalogs", diff --git a/docs/accounts/overview.mdx b/docs/accounts/overview.mdx index 0abba72129..5ba3acdc73 100644 --- a/docs/accounts/overview.mdx +++ b/docs/accounts/overview.mdx @@ -144,7 +144,7 @@ Vendor agents that support scope introspection attach an `authorization` object | `scope_name` | Optional named scope identifier. Only `attestation_verifier` is standardized (media-buy-specific, binds to the **AAO Verified (Live)** qualifier); agent-defined names MUST use the `custom:` prefix so typos of the standard value fail schema validation rather than pass through. | | `read_only` | Convenience flag. When true, mutations return `READ_ONLY_SCOPE` regardless of whether the task is in `allowed_tasks`. Omission is equivalent to `false`. Callers MUST NOT infer read-only from `allowed_tasks` alone. | -**AdCP 3.2 compact product tools.** Authorization names the actual task. A `get_products` grant does not silently authorize `request_proposals`, `refine_proposals`, `finalize_proposals`, or `decline_proposals`; sellers grant the proposal lifecycle explicitly and may scope its compact top-level fields independently. A read-only grant may permit `list_products` but rejects the four mutation-capable proposal tools. +**AdCP 3.2 compact product tools.** Authorization names the actual task. A `get_products` grant does not silently authorize `request_proposals`, `refine_proposals`, or `decline_proposals`; sellers grant the proposal lifecycle explicitly and may scope its compact top-level fields independently. A read-only grant may permit `list_products` but rejects the three mutation-capable proposal tools. ### Semantics of presence and absence diff --git a/docs/building/by-layer/L0/schemas.mdx b/docs/building/by-layer/L0/schemas.mdx index f85ea2eae5..aaa749f01a 100644 --- a/docs/building/by-layer/L0/schemas.mdx +++ b/docs/building/by-layer/L0/schemas.mdx @@ -225,6 +225,31 @@ representative schemas from every protocol; and compare the source and projected dialects across representative instances. Repeated shared schemas are stored once under `$defs` rather than recursively inlined. +#### Production surface profile + +The MCP projection also publishes a filtered production profile: + +``` +https://adcontextprotocol.org/schemas/{version}/mcp/2026-07-28/profiles/production/manifest.json +``` + +For AdCP 3.2, this is the clean active structural catalog: it excludes the +compliance-only controller and tools deprecated by 3.2, including the +`get_products` and `list_creative_formats` compatibility facades. Its schemas +remove only presentation annotations (`description`, `title`, `examples`, and +`$comment`), so validation semantics are identical to the full MCP projection. +Each manifest tool retains its `protocol` classification for deterministic +selection. + +The profile is not a recommendation to load all active AdCP tools into one +agent context. It currently spans 65 tools across the protocol families. A +production host MUST expose only the protocols and tools it implements and +SHOULD further select the smallest capability-appropriate subset for each +agent session. Use the full projection for documentation, compatibility, and +conformance; use this profile as the filtered catalog and structural validation +source from which a host builds that subset. Removing descriptions makes the +artifacts smaller, but does not by itself solve `tools/list` context cost. + AdCP 4.0 will make JSON Schema 2020-12 the canonical source dialect. That major-version migration is where the protocol may selectively use `unevaluatedProperties`, `dependentRequired`, `dependentSchemas`, and other @@ -264,6 +289,7 @@ dist/schemas/{VERSION}/ │ ├── protocol/ # Protocol tasks │ └── core/ # Core shared schemas and legacy task lifecycle schemas ├── mcp/2026-07-28/ # Self-contained JSON Schema 2020-12 tool projections +│ └── profiles/production/ # Active, non-compliance structural surface ├── core/ # Modular schemas with $ref ├── trusted-match/ # Serve-time Context Match and Identity Match schemas ├── media-buy/ diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index c549743268..ba48f5071a 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -399,15 +399,15 @@ A daypart with no declared semantics is ambiguous and MUST be rejected with `INV `idempotency_key` is **required on every state-mutating AdCP task request**. Guaranteed pure-read tasks may leave it optional, but they MUST accept and apply the replay contract when a caller supplies one. Keys are scoped per `(authenticated agent, account)` — they have no meaning across agents on the same seller, across accounts under the same agent, or across sellers. -**Product-discovery migration in 3.2.** AdCP 3.2 adds five compact tools alongside the stable `get_products` facade: +**Product-discovery migration in 3.2.** AdCP 3.2 adds four compact tools alongside the stable `get_products` facade: - `list_products` is a naturally idempotent synchronous read; its key is optional and sellers apply the replay contract when one is supplied. -- `request_proposals`, `refine_proposals`, `finalize_proposals`, and `decline_proposals` require a key because they can allocate tasks or change proposal state. +- `request_proposals`, `refine_proposals`, and `decline_proposals` require a key because they can allocate tasks or change proposal state. - `get_products` remains valid throughout 3.x as a deprecated compatibility facade, with an optional key. Sellers MUST honor the replay contract whenever the caller supplies one. The compact tools reuse legacy implementation paths where useful but are distinct logical operations. A retry MUST keep the same tool name and payload. Reusing a key on another tool is a different payload and returns `IDEMPOTENCY_CONFLICT`; callers must not switch names after an ambiguous failure. -`request_proposals` binds each opaque proposal ID to its full BrandKey (`domain` plus optional `brand_id`) and, when supplied, its seller account. `refine_proposals`, `finalize_proposals`, and `decline_proposals` resolve that binding before idempotency lookup, require every resolvable ID in a batch to share one binding, and scope the key to the resolved account or BrandKey. Callers do not repeat identity data on those mutation requests, and sellers MUST return the same not-found response for an absent proposal and a proposal outside the caller's principal/account scope. When no ID in a batch resolves, sellers use a principal-local negative-lookup scope; this permits safe replay of an all-`unable` result without creating a cross-account oracle. +`request_proposals` binds each opaque proposal ID to its full BrandKey (`domain` plus optional `brand_id`) and, when supplied, its seller account. `refine_proposals` and `decline_proposals` resolve that binding before idempotency lookup, require every resolvable ID in a batch to share one binding, and scope the key to the resolved account or BrandKey. Callers do not repeat identity data on those mutation requests, and sellers MUST return the same not-found response for an absent proposal and a proposal outside the caller's principal/account scope. When no ID in a batch resolves, sellers use a principal-local negative-lookup scope; this permits safe replay of an all-`unable` result without creating a cross-account oracle. This section applies only to AdCP task requests. OpenRTB bid streams have their own semantics (`BidRequest.id` is a transaction ID, not an idempotency key) and are out of scope. @@ -577,7 +577,7 @@ When in doubt, ask whether the buyer's intent is **"give me the same answer as b **Bootstrap carve-out — `get_adcp_capabilities`.** The discovery call itself is exempt from any schema-level key requirement. `get_adcp_capabilities` is how the buyer learns whether the seller declares `adcp.idempotency.replay_ttl_seconds`, so a fail-closed rule against the discovery call would deadlock the bootstrap. Buyers MAY omit `idempotency_key` on `get_adcp_capabilities`, and sellers MUST accept the call without it. Buyers that send `idempotency_key` on `get_adcp_capabilities` (e.g., SDKs that include the field uniformly) get the standard cache behavior — but the discovery call carries no state and replay is harmless. Every task request that carries a key remains subject to rules 1–9; the fail-closed obligation below applies to retry-sensitive operations once the capability fetch has completed. -**When the seller's capability declaration is missing.** A seller whose `get_adcp_capabilities` response omits `adcp.idempotency.replay_ttl_seconds` is non-compliant. After a successful capability fetch, client SDKs MUST fail closed before any state-mutating task — including `request_proposals`, `refine_proposals`, `finalize_proposals`, and `decline_proposals` — so the buyer learns about the non-compliance before a silent duplicate. A guaranteed pure-read task whose schema leaves the key optional MAY proceed without replay protection, but the client MUST NOT silently treat a retry as deduplicated. +**When the seller's capability declaration is missing.** A seller whose `get_adcp_capabilities` response omits `adcp.idempotency.replay_ttl_seconds` is non-compliant. After a successful capability fetch, client SDKs MUST fail closed before any state-mutating task — including `request_proposals`, `refine_proposals`, and `decline_proposals` — so the buyer learns about the non-compliance before a silent duplicate. A guaranteed pure-read task whose schema leaves the key optional MAY proceed without replay protection, but the client MUST NOT silently treat a retry as deduplicated. **Decoding seller-emitted error codes.** Sellers MAY return error codes (`IDEMPOTENCY_CONFLICT`, `IDEMPOTENCY_EXPIRED`, `IDEMPOTENCY_IN_FLIGHT`, `INVALID_REQUEST`, or codes added in later minor versions) that buyers' pinned vocabulary may not recognize. Receivers MUST decode these per [Forward-compatible decoding (normative)](/docs/building/by-layer/L3/error-handling#forward-compatible-decoding-normative) — read `error.recovery` for the recovery classification, default to `transient` when `recovery` is absent, and never reject the response because the code value is unfamiliar. The retry semantics for `transient`-classified errors are bounded by [§ Retry Logic](/docs/building/by-layer/L3/error-handling#retry-logic) (`maxRetries` and exponential backoff with jitter) — buyers MUST NOT loop indefinitely on a `transient` default. diff --git a/docs/building/by-layer/L3/error-handling.mdx b/docs/building/by-layer/L3/error-handling.mdx index ba673e1626..498bd6835f 100644 --- a/docs/building/by-layer/L3/error-handling.mdx +++ b/docs/building/by-layer/L3/error-handling.mdx @@ -493,9 +493,9 @@ The wire-level `recovery: "correctable"` on the sandbox-only path is the registe |------|----------|-------------|------------| | `PRODUCT_NOT_FOUND` | correctable | Referenced product IDs are unknown or expired | Remove invalid IDs, or re-discover with [`get_products`](/docs/media-buy/task-reference/get_products) | | [`PRODUCT_UNAVAILABLE`](/docs/building/verification/compliance-catalog#error-code-product-unavailable) | correctable | Product is sold out or no longer available | Choose a different product | -| [`PROPOSAL_EXPIRED`](/docs/building/verification/compliance-catalog#error-code-proposal-expired) | correctable | Referenced proposal has passed its `expires_at` | Call [`request_proposals`](/docs/media-buy/task-reference/request_proposals), then [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals), to get a fresh committed proposal | -| `PROPOSAL_NOT_FOUND` | correctable | `proposal_id` is unknown to the seller (never finalized, wrong tenant, or evicted from cache) | Request and finalize a current proposal, then retry | -| [`MULTI_FINALIZE_UNSUPPORTED`](/docs/building/verification/compliance-catalog#error-code-multi-finalize-unsupported) | correctable | A request attempted to finalize multiple proposals where the seller cannot guarantee atomic commitment | Sequence single-proposal [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals) calls | +| [`PROPOSAL_EXPIRED`](/docs/building/verification/compliance-catalog#error-code-proposal-expired) | correctable | Referenced proposal has passed its `expires_at` | Call [`request_proposals`](/docs/media-buy/task-reference/request_proposals) to get a fresh executable proposal | +| `PROPOSAL_NOT_FOUND` | correctable | `proposal_id` is unknown to the seller (never issued, wrong tenant, or evicted from cache) | Call [`request_proposals`](/docs/media-buy/task-reference/request_proposals) to obtain a fresh executable proposal, then retry | +| [`MULTI_FINALIZE_UNSUPPORTED`](/docs/building/verification/compliance-catalog#error-code-multi-finalize-unsupported) | correctable | A legacy `get_products` request attempted to finalize multiple proposals where the seller cannot guarantee atomic commitment | Sequence single-proposal legacy `get_products` finalize calls | | [`REQUOTE_REQUIRED`](/docs/building/verification/compliance-catalog#error-code-requote-required) | correctable | Requested update falls outside the envelope (budget, dates, volume, targeting) the original quote was priced against; `pricing_option` remains locked | Adjust the update to fit the current quote, rediscover products/terms, add packages when available, or create a separate media buy. 3.1 does not define an amendment-quote artifact for [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy). | | `SIGNAL_NOT_FOUND` | correctable | Referenced signal does not exist in the catalog | Verify `signal_id` via [`get_signals`](/docs/signals/tasks/get_signals), or confirm availability from this agent | | [`AUDIENCE_TOO_SMALL`](/docs/building/verification/compliance-catalog#error-code-audience-too-small) | correctable | Audience segment below minimum size | Broaden targeting or upload more audience members | diff --git a/docs/media-buy/product-discovery/media-products.mdx b/docs/media-buy/product-discovery/media-products.mdx index 05fc706495..5f06375056 100644 --- a/docs/media-buy/product-discovery/media-products.mdx +++ b/docs/media-buy/product-discovery/media-products.mdx @@ -1144,7 +1144,7 @@ To execute a committed proposal, provide the `proposal_id` and `total_budget` in For a fixed proposal, the publisher converts allocation percentages into packages: - `ch_desktop_de`: 20% × \$50,000 = \$10,000 -Finalization is the seller commitment step: it firms pricing, terms, availability, and any inventory hold. It is not buyer acceptance. `create_media_buy(proposal_id)` is the acceptance/execution step. Sellers reject attempts to execute a draft proposal with `PROPOSAL_NOT_COMMITTED`; call `finalize_proposals` before retrying create. The legacy `get_products` finalize form remains supported throughout 3.x. +The split 3.2 [`request_proposals`](/docs/media-buy/task-reference/request_proposals) and [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) tasks return executable immutable snapshots with firm terms and any inventory hold. `create_media_buy(proposal_id)` is the acceptance/execution step. Legacy `get_products` may still return drafts; sellers reject attempts to execute one with `PROPOSAL_NOT_COMMITTED`, and its finalize form remains supported throughout 3.x. - `ch_desktop_fr`: 30% × \$50,000 = \$15,000 - etc. diff --git a/docs/media-buy/product-discovery/refinement.mdx b/docs/media-buy/product-discovery/refinement.mdx index 464a2ff186..e40cadd1d6 100644 --- a/docs/media-buy/product-discovery/refinement.mdx +++ b/docs/media-buy/product-discovery/refinement.mdx @@ -273,7 +273,7 @@ Client implementations should validate refinement requests against the [request |------------|------|------------| | `PRODUCT_NOT_FOUND` | One or more referenced product IDs are unknown or expired | Remove invalid IDs and retry, or re-discover with a `brief` request | | `PROPOSAL_EXPIRED` | A referenced proposal ID has passed its `expires_at` | Re-discover with a new `brief` or `wholesale` request | -| `PROPOSAL_NOT_FOUND` | The referenced `proposal_id` is unknown to the seller (never finalized, wrong tenant, or evicted from cache) | Re-issue `get_products` in `refine` mode with `action: 'finalize'` to obtain a current proposal_id | +| `PROPOSAL_NOT_FOUND` | The referenced `proposal_id` is unknown to the seller (never issued, wrong tenant, or evicted from cache) | Re-issue `get_products` in `brief` or `wholesale` mode to obtain a fresh draft; finalize that recognized proposal before creating the media buy | | `MULTI_FINALIZE_UNSUPPORTED` | `refine[]` carried multiple `action: 'finalize'` entries but the seller cannot guarantee atomic multi-proposal commit | Sequence single-proposal finalize calls (one finalize per `get_products` call) | | `INVALID_REQUEST` | `refine` provided in `brief` or `wholesale` mode, empty `refine` array, or missing required fields | Check `buying_mode` and required fields | diff --git a/docs/media-buy/specification.mdx b/docs/media-buy/specification.mdx index 143b875107..b2aa5c6d0c 100644 --- a/docs/media-buy/specification.mdx +++ b/docs/media-buy/specification.mdx @@ -548,7 +548,7 @@ Sales agents SHOULD target the following response times: ### Idempotency -Every state-mutating AdCP task request MUST carry an `idempotency_key`. In AdCP 3.2, product discovery uses explicit contracts: `request_proposals`, `refine_proposals`, `finalize_proposals`, and `decline_proposals` require a key; `list_products` and the legacy 3.x `get_products` facade leave it optional. Sales agents MUST apply the replay contract whenever a key is supplied: +Every state-mutating AdCP task request MUST carry an `idempotency_key`. In AdCP 3.2, product discovery uses explicit contracts: `request_proposals`, `refine_proposals`, and `decline_proposals` require a key; `list_products` and the legacy 3.x `get_products` facade leave it optional. Sales agents MUST apply the replay contract whenever a key is supplied: - The same key and equivalent canonical payload within the replay window MUST return the original response without re-executing the request. - The same key with a different canonical payload MUST return `IDEMPOTENCY_CONFLICT`. diff --git a/docs/media-buy/task-reference/create_media_buy.mdx b/docs/media-buy/task-reference/create_media_buy.mdx index 1d6610e880..e5cc166897 100644 --- a/docs/media-buy/task-reference/create_media_buy.mdx +++ b/docs/media-buy/task-reference/create_media_buy.mdx @@ -141,7 +141,7 @@ npx @adcp/sdk@latest \ | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `account` | [account-ref](/docs/building/by-layer/L2/accounts-and-agents#account-references) | Yes | Account reference. Pass `{ "account_id": "..." }` or `{ "brand": {...}, "operator": "..." }` if the seller supports implicit resolution. Required for billing and policy evaluation. | -| `proposal_id` | string | No* | ID of the exact committed proposal snapshot from [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals). For IDs from the 3.2 split lifecycle, draft proposals fail with [`PROPOSAL_NOT_COMMITTED`](/docs/building/verification/compliance-catalog#error-code-proposal-not-committed), while declined or already executed proposals fail with [`INVALID_STATE`](/docs/building/verification/compliance-catalog#error-code-invalid-state). Exact retries reuse the original idempotency key and replay success. Legacy [`get_products`](/docs/media-buy/task-reference/get_products) proposals retain 3.x behavior. | +| `proposal_id` | string | No* | ID of the exact executable proposal snapshot from [`request_proposals`](/docs/media-buy/task-reference/request_proposals) or [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals). Declined or already executed proposals fail with [`INVALID_STATE`](/docs/building/verification/compliance-catalog#error-code-invalid-state). Exact retries reuse the original idempotency key and replay success. Legacy [`get_products`](/docs/media-buy/task-reference/get_products) drafts fail with [`PROPOSAL_NOT_COMMITTED`](/docs/building/verification/compliance-catalog#error-code-proposal-not-committed) until finalized through that compatibility task. | | `opportunity` | OpportunityContext | No | Proposal-mode planning-cycle closure shared with [`request_proposals`](/docs/media-buy/task-reference/request_proposals) and [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals); requires `proposal_id`. Omitting status is the create-specific signal to infer closed with `accepted_with_seller`; if status is sent, it must explicitly carry that closure. | | `total_budget` | TotalBudget | No* | Hard aggregate lifetime budget. Its currency is the single media-buy denomination for package constraints and canonical bidding. Required for proposals and seller-optimized explicit packages. Optional in fixed explicit-package mode; when supplied there, it must equal the sum of package budgets. | | `budget_allocation` | BudgetAllocation | No | Cross-package allocation mode. Omit for fixed allocation. Use `seller_optimized` with media-buy optimization goals to delegate allocation to the seller. Must be omitted when executing a proposal because the committed proposal supplies it. | @@ -160,7 +160,7 @@ npx @adcp/sdk@latest \ \* Either `packages` OR (`proposal_id` + `total_budget`) must be provided. -When executing a proposal, `proposal_status` on the returned proposal determines whether `create_media_buy` is valid. `committed` proposals can be executed before `expires_at`; `draft` proposals require a prior [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals) call. Finalization is seller commitment to firm terms, not buyer acceptance. This `create_media_buy` call is the acceptance/execution step. The legacy [`get_products`](/docs/media-buy/task-reference/get_products) finalize form remains supported throughout 3.x. +When executing a proposal, `proposal_status` on the returned proposal determines whether `create_media_buy` is valid. The split 3.2 request and refine tasks return `committed` snapshots that can be executed before `expires_at`; `create_media_buy` is the buyer acceptance/execution step. Legacy [`get_products`](/docs/media-buy/task-reference/get_products) drafts require its finalize form, which remains supported throughout 3.x. ### TotalBudget Object @@ -889,7 +889,7 @@ asyncio.run(create_with_reporting()) ### Executing a Proposal -Execute a committed proposal from [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals) without manually constructing packages: +Execute a committed proposal from [`request_proposals`](/docs/media-buy/task-reference/request_proposals) or [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) without manually constructing packages: @@ -902,7 +902,7 @@ const endDate = new Date(); endDate.setDate(endDate.getDate() + 90); const result = await testAgent.createMediaBuy({ - proposal_id: 'swiss_balanced_v1', // From finalize_proposals response + proposal_id: 'swiss_balanced_v1', // From request_proposals or refine_proposals total_budget: { amount: 50000, currency: 'USD' @@ -941,7 +941,7 @@ async def execute_proposal(): end_date = datetime.now(timezone.utc) + timedelta(days=90) result = await test_agent.simple.create_media_buy( - proposal_id='swiss_balanced_v1', # From finalize_proposals response + proposal_id='swiss_balanced_v1', # From request_proposals or refine_proposals total_budget={ 'amount': 50000, 'currency': 'USD' diff --git a/docs/media-buy/task-reference/decline_proposals.mdx b/docs/media-buy/task-reference/decline_proposals.mdx index 0ec11db21e..fd6a97f57d 100644 --- a/docs/media-buy/task-reference/decline_proposals.mdx +++ b/docs/media-buy/task-reference/decline_proposals.mdx @@ -5,7 +5,7 @@ description: "Terminally decline one or more immutable proposals." testable: true --- -`decline_proposals` records that the buyer evaluated one or more proposals and will not pursue them. The operation is terminal: a seller must reject later [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals) or new logical [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) attempts for a declined proposal with [`INVALID_STATE`](/docs/building/verification/compliance-catalog#error-code-invalid-state). Exact retries of an earlier successful call still follow the shared idempotency replay contract. A proposal that was already executed cannot subsequently be declined and returns `unable`. +`decline_proposals` records that the buyer evaluated one or more proposals and will not pursue them. The operation is terminal: a seller must reject later [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) or new logical [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) attempts for a declined proposal with [`INVALID_STATE`](/docs/building/verification/compliance-catalog#error-code-invalid-state). Exact retries of an earlier successful call still follow the shared idempotency replay contract. A proposal that was already executed cannot subsequently be declined and returns `unable`. **Request schema:** [`/schemas/v3/media-buy/decline-proposals-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/decline-proposals-request.json) diff --git a/docs/media-buy/task-reference/finalize_proposals.mdx b/docs/media-buy/task-reference/finalize_proposals.mdx deleted file mode 100644 index 3ca526c49d..0000000000 --- a/docs/media-buy/task-reference/finalize_proposals.mdx +++ /dev/null @@ -1,21 +0,0 @@ ---- -title: finalize_proposals -description: "Atomically commit one or more draft product proposals." -"og:title": "AdCP finalize_proposals Task" -testable: true ---- - -`finalize_proposals` is the explicit proposal commit boundary. `idempotency_key` is required, and `proposal_ids` must be non-empty and unique. The request carries no brand, account, catalog, or targeting state: each opaque proposal ID is resolved under the authenticated principal and already binds that state. - -**Request schema:** [`/schemas/v3/media-buy/finalize-proposals-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/finalize-proposals-request.json) - -```json -{ - "idempotency_key": "550e8400-e29b-41d4-a716-446655441003", - "proposal_ids": ["proposal_123", "proposal_456"] -} -``` - -The seller advertises `max_atomic_finalize_batch_size`. Within that bound, finalization is all-or-nothing: every named proposal is persisted as committed, or none is. Retrying an already successful request returns the original committed snapshots and does not extend their inventory holds. - -Finalization changes lifecycle state without changing the proposal's commercial terms, so the committed snapshot retains its `proposal_id`. A declined proposal cannot be finalized; request a new proposal instead. diff --git a/docs/media-buy/task-reference/get_products.mdx b/docs/media-buy/task-reference/get_products.mdx index 58aea68e38..4425e63e4f 100644 --- a/docs/media-buy/task-reference/get_products.mdx +++ b/docs/media-buy/task-reference/get_products.mdx @@ -8,7 +8,7 @@ testable: true Discover available advertising products based on campaign requirements using natural language briefs or structured filters. -`get_products` is deprecated for new integrations in AdCP 3.2, but remains fully supported throughout 3.x. Use [`list_products`](/docs/media-buy/task-reference/list_products), [`request_proposals`](/docs/media-buy/task-reference/request_proposals), [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals), [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals), or [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals). Its `idempotency_key` remains optional. Split-task retries keep the same tool name; they are not cross-name replays of this compatibility facade. +`get_products` is deprecated for new integrations in AdCP 3.2, but remains fully supported throughout 3.x. Use [`list_products`](/docs/media-buy/task-reference/list_products), [`request_proposals`](/docs/media-buy/task-reference/request_proposals), [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals), or [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals). Its `idempotency_key` remains optional. Split-task retries keep the same tool name; they are not cross-name replays of this compatibility facade. @@ -1353,7 +1353,7 @@ The rejection arm carries no `products`, `proposals`, `incomplete`, `filter_diag | [`INVALID_REQUEST`](/docs/building/verification/compliance-catalog#error-code-invalid-request) | Brief too long or malformed filters | Check request parameters | | [`PRODUCT_NOT_FOUND`](/docs/building/verification/compliance-catalog#error-code-product-not-found) | One or more referenced product IDs are unknown or expired | Remove invalid IDs and retry, or re-discover with a `brief` request | | [`PROPOSAL_EXPIRED`](/docs/building/verification/compliance-catalog#error-code-proposal-expired) | A referenced proposal ID has passed its `expires_at` timestamp | Re-discover with a new `brief` or `wholesale` request | -| [`PROPOSAL_NOT_FOUND`](/docs/building/verification/compliance-catalog#error-code-proposal-not-found) | The referenced `proposal_id` is unknown to the seller (never finalized, wrong tenant, or evicted from cache) | Re-issue `get_products` in `refine` mode with `action: 'finalize'` to obtain a current proposal_id | +| [`PROPOSAL_NOT_FOUND`](/docs/building/verification/compliance-catalog#error-code-proposal-not-found) | The referenced `proposal_id` is unknown to the seller (never issued, wrong tenant, or evicted from cache) | Re-issue `get_products` in `brief` or `wholesale` mode to obtain a fresh draft; finalize that recognized proposal before creating the media buy | | [`MULTI_FINALIZE_UNSUPPORTED`](/docs/building/verification/compliance-catalog#error-code-multi-finalize-unsupported) | `refine[]` carried multiple `action: 'finalize'` entries but the seller cannot guarantee atomic multi-proposal commit | Sequence single-proposal finalize calls — one finalize entry per `get_products` call | | [`POLICY_VIOLATION`](/docs/building/verification/compliance-catalog#error-code-policy-violation) | The request itself violates an applicable policy and must be corrected | Review the structured policy details and revise the request. A seller making a deliberate business decision to decline an otherwise well-formed brief uses `status: "rejected"` instead. | diff --git a/docs/media-buy/task-reference/index.mdx b/docs/media-buy/task-reference/index.mdx index fb53e6f682..229f376360 100644 --- a/docs/media-buy/task-reference/index.mdx +++ b/docs/media-buy/task-reference/index.mdx @@ -16,7 +16,6 @@ Complete reference for all AdCP Media Buy tasks. Each task is designed for AI ag | [`list_products`](/docs/media-buy/task-reference/list_products) | Enumerate structured product offers | ~1s | Discovery | | [`request_proposals`](/docs/media-buy/task-reference/request_proposals) | Request seller-authored plans | ~60s | Planning | | [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) | Create immutable proposal revisions | ~60s | Planning | -| [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals) | Commit proposal terms and inventory holds | Minutes-Days | Planning | | [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals) | Record terminal proposal feedback | ~1s | Planning | | [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) | Create campaigns from selected products | Minutes-Days | Media Buys | | [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy) | Modify campaign settings and budgets | Minutes-Days | Media Buys | @@ -71,7 +70,7 @@ Start here to understand what's available and plan your campaign. - **[`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities)** - Discover agent capabilities, portfolio, and supported features (protocol-level task) - **[`get_products`](/docs/media-buy/task-reference/get_products)** - The core discovery task using natural language briefs - **[`list_products`](/docs/media-buy/task-reference/list_products)** - Compact structured product-offer reads -- **[`request_proposals`](/docs/media-buy/task-reference/request_proposals)**, **[`refine_proposals`](/docs/media-buy/task-reference/refine_proposals)**, **[`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals)**, and **[`decline_proposals`](/docs/media-buy/task-reference/decline_proposals)** - Explicit proposal lifecycle operations +- **[`request_proposals`](/docs/media-buy/task-reference/request_proposals)**, **[`refine_proposals`](/docs/media-buy/task-reference/refine_proposals)**, and **[`decline_proposals`](/docs/media-buy/task-reference/decline_proposals)** - Explicit proposal lifecycle operations; requested and refined proposals are executable snapshots - **[Canonical formats](/docs/creative/canonical-formats)** - Understand creative requirements and authority ### Media Buy Management @@ -127,7 +126,7 @@ Schemas are accessible at runtime via the documentation server for validation an Task names use snake_case and follow verb-first semantics consistently across Media Buy: - `get_*`: Retrieve current state or scoped datasets (for example [`get_products`](/docs/media-buy/task-reference/get_products), [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys), [`get_media_buy_delivery`](/docs/media-buy/task-reference/get_media_buy_delivery)) - `list_*`: Enumerate collections with optional filtering (for example [`list_creatives`](/docs/creative/task-reference/list_creatives)) -- `request_*`, `refine_*`, `finalize_*`, and `decline_*`: Explicit lifecycle transitions over immutable resources +- `request_*`, `refine_*`, and `decline_*`: Explicit lifecycle transitions over immutable resources; executable proposals flow directly into [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) - `create_*`: Create new resources ([`create_media_buy`](/docs/media-buy/task-reference/create_media_buy)) - `update_*`: Apply partial updates to existing resources ([`update_media_buy`](/docs/media-buy/task-reference/update_media_buy)) - `sync_*`: Reconcile external state into seller systems with upsert-like behavior ([`sync_catalogs`](/docs/media-buy/task-reference/sync_catalogs), [`sync_creatives`](/docs/creative/task-reference/sync_creatives), [`sync_event_sources`](/docs/media-buy/task-reference/sync_event_sources)) diff --git a/docs/media-buy/task-reference/refine_proposals.mdx b/docs/media-buy/task-reference/refine_proposals.mdx index c77ac63da8..04e91100dd 100644 --- a/docs/media-buy/task-reference/refine_proposals.mdx +++ b/docs/media-buy/task-reference/refine_proposals.mdx @@ -1,11 +1,11 @@ --- title: refine_proposals -description: "Create revised drafts from one or more proposals." +description: "Create executable revisions from one or more proposals." "og:title": "AdCP refine_proposals Task" testable: true --- -`refine_proposals` creates revised drafts without committing them. Each entry targets one proposal, proposal IDs must be unique within the request, and results preserve request order. Successful refinement returns a new `proposal_id`; that ID is the complete version identity for the immutable commercial snapshot, and the source remains addressable for comparison or parallel exploration. +`refine_proposals` creates executable committed revisions directly. Each entry targets one proposal, proposal IDs must be unique within the request, and results preserve request order. Successful refinement returns a new `proposal_id`; that ID is the complete version identity for the immutable commercial snapshot, and the source remains addressable for comparison or parallel exploration. **Request schema:** [`/schemas/v3/media-buy/refine-proposals-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/refine-proposals-request.json) @@ -25,6 +25,6 @@ testable: true } ``` -The response contains one ordered `results[]` entry per request. `revised` means the requested change was applied, `partial` returns an honest draft plus explanatory notes, and `unable` returns a reason without minting a misleading revision. Refinement never commits pricing or inventory; use [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals) when revisions have converged or [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals) when the buyer stops pursuing one. +The response contains one ordered `results[]` entry per request. `revised` means the requested change was applied, `partial` returns an honest executable revision plus explanatory notes, and `unable` returns a reason without minting a misleading revision. Every returned revision is an immutable committed snapshot that can be passed directly to [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy), or to [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals) when the buyer stops pursuing it. Every revised proposal inherits the source proposal's opportunity association. Callers do not repeat opportunity context during refinement. diff --git a/docs/media-buy/task-reference/request_proposals.mdx b/docs/media-buy/task-reference/request_proposals.mdx index 18148b103a..64a38bad31 100644 --- a/docs/media-buy/task-reference/request_proposals.mdx +++ b/docs/media-buy/task-reference/request_proposals.mdx @@ -5,7 +5,7 @@ description: "Request one or more actionable seller-authored media plans." testable: true --- -`request_proposals` creates one or more draft media-plan proposals from a brief. It can begin a consultative workflow directly or use `product_ids` returned by [`list_products`](/docs/media-buy/task-reference/list_products). Success always contains at least one proposal; returning products without a proposal does not satisfy this task. +`request_proposals` creates one or more executable media-plan proposal snapshots from a brief. It can begin a consultative workflow directly or use `product_ids` returned by [`list_products`](/docs/media-buy/task-reference/list_products). Success always contains at least one proposal; returning products without a proposal does not satisfy this task. `brand` is required and contains only the stable BrandKey (`domain` plus optional `brand_id`); the seller resolves the canonical brand manifest rather than receiving a full brand file in every call. `account_id` is optional and adds seller-specific commercial terms when the buyer already knows its account. @@ -28,8 +28,8 @@ testable: true } ``` -Every returned allocation references the exact `product_id` whose pricing and forecast the seller used. The returned `proposal_id` is the only proposal linkage needed by [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals), [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals), [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals), and [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy). Each ID identifies one immutable commercial snapshot; refinement mints a new ID instead of adding a second version field. +Every returned allocation references the exact `product_id` whose pricing and forecast the seller used. The returned `proposal_id` is the only proposal linkage needed by [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals), [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals), and [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy). Each ID identifies one executable immutable commercial snapshot; refinement mints a new ID instead of adding a second version field. `opportunity` is optional shared planning-cycle context and must be open when supplied here. Its buyer-assigned `opportunity_id` can span request, decline, and purchase calls without becoming part of proposal identity. Sellers associate it with every proposal created by the request, and revised proposals inherit the same association. -The response uses `outcome: "proposed"` for a successful draft set and `outcome: "rejected"` with a reason when the seller cannot construct an actionable plan. +The response uses `outcome: "proposed"` for a successful executable set and `outcome: "rejected"` with a reason when the seller cannot construct an actionable plan. diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index 34f49e3846..2a50aeeb8f 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -90,7 +90,7 @@ Core AdCP protocol information: #### idempotency -Declares whether this seller honors `idempotency_key` replay protection. AdCP requires keys on mutating requests. In the compact AdCP 3.2 product lifecycle, [`request_proposals`](/docs/media-buy/task-reference/request_proposals), [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals), [`finalize_proposals`](/docs/media-buy/task-reference/finalize_proposals), and [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals) require keys; [`list_products`](/docs/media-buy/task-reference/list_products) and the legacy 3.x [`get_products`](/docs/media-buy/task-reference/get_products) facade leave the key optional. See [security.mdx § Idempotency](/docs/building/by-layer/L1/security#idempotency). Clients MUST NOT assume a default; a seller without this block is non-compliant and should be treated as unsafe for retry-sensitive operations. +Declares whether this seller honors `idempotency_key` replay protection. AdCP requires keys on mutating requests. In the compact AdCP 3.2 product lifecycle, [`request_proposals`](/docs/media-buy/task-reference/request_proposals), [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals), and [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals) require keys; [`list_products`](/docs/media-buy/task-reference/list_products) and the legacy 3.x [`get_products`](/docs/media-buy/task-reference/get_products) facade leave the key optional. See [security.mdx § Idempotency](/docs/building/by-layer/L1/security#idempotency). Clients MUST NOT assume a default; a seller without this block is non-compliant and should be treated as unsafe for retry-sensitive operations. | Field | Type | Description | |-------|------|-------------| @@ -364,9 +364,7 @@ Media-buy protocol capabilities. Only present if `media_buy` is in `supported_pr #### product_discovery_tools -AdCP 3.2 sellers use `product_discovery_tools` to advertise any supported subset of `get_products`, `list_products`, `request_proposals`, `refine_proposals`, `finalize_proposals`, and `decline_proposals`. When absent, buyers use the legacy `get_products` facade and inspect `buying_modes`. The compact tools share authorization policy with legacy discovery, but each stateful tool has its own idempotency identity: retry with the same tool name and payload. - -Sellers advertising `finalize_proposals` also declare `max_atomic_finalize_batch_size`. A request within that bound either commits every named proposal or commits none. +AdCP 3.2 sellers use `product_discovery_tools` to advertise any supported subset of `get_products`, `list_products`, `request_proposals`, `refine_proposals`, and `decline_proposals`. When absent, buyers use the legacy `get_products` facade and inspect `buying_modes`. The compact tools share authorization policy with legacy discovery, but each stateful tool has its own idempotency identity: retry with the same tool name and payload. :::note 3.0 breaking changes The following fields have been removed from the capabilities response: diff --git a/docs/reference/release-notes.mdx b/docs/reference/release-notes.mdx index 0cee65c2b5..e41bf2a01a 100644 --- a/docs/reference/release-notes.mdx +++ b/docs/reference/release-notes.mdx @@ -15,13 +15,13 @@ Authoritative version-by-version release record for AdCP, with cumulative change ### Compact product and proposal lifecycle (#6115) -AdCP 3.2 adds `list_products`, `request_proposals`, `refine_proposals`, `finalize_proposals`, and `decline_proposals` as compact, task-specific contracts and retains `get_products` throughout 3.x. +AdCP 3.2 adds `list_products`, `request_proposals`, `refine_proposals`, and `decline_proposals` as compact, task-specific contracts and retains `get_products` throughout 3.x. - `list_products` is a synchronous, naturally idempotent product-offer read. -- `request_proposals` creates actionable draft proposals from a brief and optional listed product IDs. -- `refine_proposals` creates immutable proposal revisions; `finalize_proposals` commits one or more revisions atomically; `decline_proposals` records terminal buyer feedback and prevents later execution. +- `request_proposals` creates executable proposals with firm terms from a brief and optional listed product IDs. +- `request_proposals` and `refine_proposals` return immutable, executable proposal snapshots; `decline_proposals` records terminal buyer feedback and prevents later execution. - `proposal_id` is the complete immutable snapshot identity, so changed terms mint a new ID rather than adding `proposal_version` to every downstream call. -- Optional shared `opportunity` context connects proposal request, decline, and purchase without expanding the identity carried by refinement and finalization. +- Optional shared `opportunity` context connects proposal request, refinement, decline, and purchase without expanding proposal snapshot identity. - Each stateful task requires an idempotency key and retains its own replay identity. Retries use the same tool name. - Brand input is an identity-only key, catalogs are compact selections, and offer filters are separated from exact targeting and future targeting support. - `media_buy.product_discovery_tools` advertises the split surface. Its absence means callers use `get_products` and `buying_modes`. diff --git a/docs/snippets/compliance-error-codes.mdx b/docs/snippets/compliance-error-codes.mdx index 35b0480e2c..dbdd26305f 100644 --- a/docs/snippets/compliance-error-codes.mdx +++ b/docs/snippets/compliance-error-codes.mdx @@ -83,9 +83,9 @@ description: "Canonical AdCP error codes with recovery classifications, remediat | `PRODUCT_EXPIRED` | correctable | re-discover with get_products to find current inventory | | `PRODUCT_NOT_FOUND` | correctable | remove invalid IDs and retry, or re-discover with get_products | | `PRODUCT_UNAVAILABLE` | correctable | choose a different product | -| `PROPOSAL_EXPIRED` | correctable | re-discover with get_products to get a fresh proposal | -| `PROPOSAL_NOT_COMMITTED` | correctable | finalize the proposal first using finalize_proposals | -| `PROPOSAL_NOT_FOUND` | correctable | request and finalize a current proposal_id, then retry | +| `PROPOSAL_EXPIRED` | correctable | call request_proposals for a fresh executable proposal, or re-discover through legacy get_products | +| `PROPOSAL_NOT_COMMITTED` | correctable | finalize the legacy draft through get_products, or request a fresh executable proposal | +| `PROPOSAL_NOT_FOUND` | correctable | request a fresh executable proposal, then retry | | `PROVENANCE_CLAIM_CONTRADICTED` | correctable | revise the provenance claim to match the verifier's observation or replace the creative; auto-retry without correction will not pass | | `PROVENANCE_DIGITAL_SOURCE_TYPE_MISSING` | correctable | set provenance.digital_source_type to a value from the digital-source-type enum and resubmit | | `PROVENANCE_DISCLOSURE_MISSING` | correctable | set provenance.disclosure.required and, when true, populate disclosure.jurisdictions | @@ -820,27 +820,27 @@ The requested product is sold out or no longer available. Recovery: correctable -**Suggested action:** re-discover with get_products to get a fresh proposal +**Suggested action:** call request_proposals for a fresh executable proposal, or re-discover through legacy get_products -A referenced proposal ID has passed its expires_at timestamp. Recovery: correctable (re-discover with get_products to get a fresh proposal). +A referenced proposal ID has passed its expires_at timestamp. Recovery: correctable (call request_proposals to get a fresh executable proposal, or re-discover through the legacy get_products facade). -**Suggested action:** finalize the proposal first using finalize_proposals +**Suggested action:** finalize the legacy draft through get_products, or request a fresh executable proposal -The referenced proposal has proposal_status 'draft' and cannot be used to create a media buy. Recovery: correctable (call finalize_proposals first; the legacy get_products finalize form remains supported throughout 3.x). +The referenced legacy get_products proposal has proposal_status 'draft' and cannot be used to create a media buy. Recovery: correctable (finalize it through get_products first). AdCP 3.2 request_proposals and refine_proposals return executable committed snapshots. -**Suggested action:** request and finalize a current proposal_id, then retry +**Suggested action:** request a fresh executable proposal, then retry -The referenced proposal_id is not recognized by the seller — never finalized, belongs to a different tenant, or evicted from the seller's session cache before consumption. Distinct from {"PROPOSAL_EXPIRED"} (a known proposal whose {"expires_at"} window has passed) and {"PROPOSAL_NOT_COMMITTED"} (a known proposal still in {"draft"}). Recovery: correctable (request and finalize a current proposal, then retry create_media_buy). +The referenced proposal_id is not recognized by the seller — it belongs to a different tenant, was never issued, or was evicted from the seller's session cache before consumption. Distinct from {"PROPOSAL_EXPIRED"} (a known proposal whose {"expires_at"} window has passed) and {"PROPOSAL_NOT_COMMITTED"} (a known legacy get_products proposal still in {"draft"}). Recovery: correctable (request a fresh executable proposal, then retry create_media_buy). diff --git a/scripts/build-schemas.cjs b/scripts/build-schemas.cjs index 507f2e29fa..4fac1860c9 100644 --- a/scripts/build-schemas.cjs +++ b/scripts/build-schemas.cjs @@ -2029,10 +2029,11 @@ async function generateBundledSchemas(sourceDir, bundledDir, version) { function generateMcpProjectionForVersion(versionDir, urlVersion) { const targetDir = path.join(versionDir, 'mcp', MCP_PROTOCOL_VERSION); + const manifestPath = path.join(versionDir, 'manifest.json'); const stats = generateMcpSchemaProjection({ sourceDir: SOURCE_DIR, targetDir, - manifestPath: path.join(versionDir, 'manifest.json'), + manifestPath, urlVersion, }); console.log( @@ -2040,6 +2041,40 @@ function generateMcpProjectionForVersion(versionDir, urlVersion) { + `${(stats.totalBytes / (1024 * 1024)).toFixed(2)} MiB total, ` + `${Math.ceil(stats.largestSchemaBytes / 1024)} KiB largest` ); + + const canonicalManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const surfaceVersion = [ + canonicalManifest.adcp_version, + ...Object.values(canonicalManifest.tools || {}).map(tool => tool.added_in).filter(Boolean), + ].filter(version => semver.valid(version)).sort(semver.rcompare)[0]; + const productionTargetDir = path.join(targetDir, 'profiles', 'production'); + const productionStats = generateMcpSchemaProjection({ + sourceDir: SOURCE_DIR, + targetDir: productionTargetDir, + manifestPath, + urlVersion, + schemaUrlPrefix: `${urlVersion}/mcp/${MCP_PROTOCOL_VERSION}/profiles/production`, + annotationMode: 'structural', + toolFilter: (_toolName, tool) => ( + tool.protocol !== 'compliance' + && (!tool.added_in || semver.lte(tool.added_in, surfaceVersion)) + && (!tool.deprecated_in || semver.gt(tool.deprecated_in, surfaceVersion)) + ), + manifestMetadata: { + profile: 'production', + surface_version: surfaceVersion, + filters: { + exclude_protocols: ['compliance'], + exclude_deprecated: true, + }, + canonical_projection: '../../manifest.json', + }, + }); + console.log( + ` ✓ Production ${surfaceVersion} profile: ${productionStats.toolCount} tools, ` + + `${productionStats.schemaCount} schemas, ` + + `${(productionStats.totalBytes / (1024 * 1024)).toFixed(2)} MiB structural projection` + ); return stats; } diff --git a/scripts/lint-storyboard-scoping.cjs b/scripts/lint-storyboard-scoping.cjs index 74eeefee48..a732bb4434 100644 --- a/scripts/lint-storyboard-scoping.cjs +++ b/scripts/lint-storyboard-scoping.cjs @@ -49,7 +49,6 @@ const TENANT_SCOPED_TASKS = new Set([ 'list_products', 'request_proposals', 'refine_proposals', - 'finalize_proposals', 'decline_proposals', 'get_signals', 'activate_signal', diff --git a/scripts/mcp-schema-projection.cjs b/scripts/mcp-schema-projection.cjs index c7e23979da..dbf47bd964 100644 --- a/scripts/mcp-schema-projection.cjs +++ b/scripts/mcp-schema-projection.cjs @@ -12,6 +12,7 @@ const SCHEMA_ORIGIN = 'https://adcontextprotocol.org'; const MAX_SCHEMA_DEPTH = 64; const MAX_SCHEMA_OBJECTS = 10_000; const MAX_SCHEMA_BYTES = 4 * 1024 * 1024; +const PRESENTATION_ANNOTATIONS = new Set(['$comment', 'description', 'examples', 'title']); const POST_DRAFT_07_KEYWORDS = new Set([ '$anchor', @@ -509,6 +510,20 @@ function measureSchema(schema) { }; } +/** + * Remove presentation-only annotations without changing validation semantics. + * Walk only schema-bearing keywords so payloads in const/default/enum values + * are never rewritten merely because they contain a key named "description". + */ +function stripPresentationAnnotations(schema) { + const stripped = clone(schema); + walkSchema(stripped, node => { + if (!node || typeof node !== 'object' || Array.isArray(node)) return; + for (const annotation of PRESENTATION_ANNOTATIONS) delete node[annotation]; + }); + return stripped; +} + function enforceSchemaBounds(schema, label) { const metrics = measureSchema(schema); if (metrics.depth > MAX_SCHEMA_DEPTH) { @@ -523,11 +538,21 @@ function enforceSchemaBounds(schema, label) { return metrics; } -function projectSourceSchema(schema, rootFile, sourceDir, urlVersion, relativePath) { +function projectSourceSchema( + schema, + rootFile, + sourceDir, + urlVersion, + relativePath, + annotationMode = 'full', + schemaUrlPrefix = `${urlVersion}/mcp/${MCP_PROTOCOL_VERSION}`, +) { const compact = compactDraft07Schema(schema, rootFile, sourceDir); - const projected = projectDraft07Node(compact); + let projected = projectDraft07Node(compact); + if (annotationMode === 'structural') projected = stripPresentationAnnotations(projected); + else if (annotationMode !== 'full') throw new Error(`Unknown annotation mode ${JSON.stringify(annotationMode)}`); projected.$schema = JSON_SCHEMA_2020_12; - projected.$id = `${SCHEMA_ORIGIN}/schemas/${urlVersion}/mcp/${MCP_PROTOCOL_VERSION}/${relativePath}`; + projected.$id = `${SCHEMA_ORIGIN}/schemas/${schemaUrlPrefix}/${relativePath}`; delete projected._bundled; const externalRefs = collectExternalRefs(projected); @@ -544,7 +569,16 @@ function writeJson(filename, value) { fs.writeFileSync(filename, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); } -function generateMcpSchemaProjection({ sourceDir, targetDir, manifestPath, urlVersion }) { +function generateMcpSchemaProjection({ + sourceDir, + targetDir, + manifestPath, + urlVersion, + annotationMode = 'full', + toolFilter = () => true, + manifestMetadata = {}, + schemaUrlPrefix = `${urlVersion}/mcp/${MCP_PROTOCOL_VERSION}`, +}) { const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const projectedTools = {}; const generated = new Set(); @@ -556,7 +590,8 @@ function generateMcpSchemaProjection({ sourceDir, targetDir, manifestPath, urlVe fs.mkdirSync(targetDir, { recursive: true }); for (const [toolName, tool] of Object.entries(manifest.tools || {})) { - const projectedTool = {}; + if (!toolFilter(toolName, tool)) continue; + const projectedTool = { protocol: tool.protocol }; for (const [field, relativePath] of [ ['inputSchema', tool.request_schema], ['outputSchema', tool.response_schema], @@ -569,7 +604,15 @@ function generateMcpSchemaProjection({ sourceDir, targetDir, manifestPath, urlVe const sourceSchema = JSON.parse(fs.readFileSync(sourcePath, 'utf8')); let projectedSchema; try { - projectedSchema = projectSourceSchema(sourceSchema, sourcePath, sourceDir, urlVersion, relativePath); + projectedSchema = projectSourceSchema( + sourceSchema, + sourcePath, + sourceDir, + urlVersion, + relativePath, + annotationMode, + schemaUrlPrefix, + ); } catch (error) { throw new Error(`${relativePath}: ${error.message}`); } @@ -591,6 +634,8 @@ function generateMcpSchemaProjection({ sourceDir, targetDir, manifestPath, urlVe source_schema_dialect: JSON_SCHEMA_DRAFT_07, compatibility: 'semantics-preserving projection; no 4.0 strictness rules applied', delivery: 'downloadable schema artifacts; servers choose which schemas to embed in tools/list', + annotation_mode: annotationMode, + ...manifestMetadata, tools: projectedTools, }; writeJson(path.join(targetDir, 'manifest.json'), projectionManifest); @@ -619,4 +664,5 @@ module.exports = { measureSchema, projectDraft07Node, projectSourceSchema, + stripPresentationAnnotations, }; diff --git a/server/src/training-agent/idempotency.ts b/server/src/training-agent/idempotency.ts index 9c4d1043fc..c04dbf40a8 100644 --- a/server/src/training-agent/idempotency.ts +++ b/server/src/training-agent/idempotency.ts @@ -69,7 +69,6 @@ export const MUTATING_TOOLS: ReadonlySet = new Set([ 'delete_collection_list', 'delete_property_list', 'decline_proposals', - 'finalize_proposals', 'log_event', 'provide_performance_feedback', 'request_proposals', diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index aea05f87e5..51ba2db3fe 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -1419,6 +1419,46 @@ function proposalLifecycle(proposal: Proposal): ProposalLifecycle { return proposal as unknown as ProposalLifecycle; } +/** Return an exact proposal snapshot that can be accepted directly through + * create_media_buy. The compact 3.2 lifecycle has no separate finalize step; + * legacy get_products may still use this helper for its finalize refinement. */ +function executableProposalSnapshot(proposal: Proposal, brandDomain?: string): Proposal { + const executable = { ...proposal } as Record & ProposalLifecycle; + executable.proposal_status = 'committed'; + executable.expires_at = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + + const hasGuaranteed = proposal.allocations.some(allocation => { + const catalogProduct = getCatalog().find(entry => entry.product.product_id === allocation.product_id); + return catalogProduct?.product.delivery_type === 'guaranteed'; + }); + if (hasGuaranteed) { + const publisherProduct = getCatalog().find( + entry => entry.product.product_id === proposal.allocations[0].product_id, + ); + const ioDigest = createHash('sha256') + .update(`${proposal.proposal_id}:${brandDomain ?? 'advertiser.example'}`) + .digest('hex') + .slice(0, 24); + executable.insertion_order = { + io_id: `io_${ioDigest}`, + terms: { + advertiser: brandDomain ?? 'advertiser.example', + publisher: publisherProduct?.publisherId || 'unknown', + total_budget: { + amount: proposal.total_budget_guidance?.recommended ?? 0, + currency: proposal.total_budget_guidance?.currency ?? 'USD', + }, + flight_start: new Date().toISOString(), + flight_end: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + payment_terms: 'net_30', + }, + requires_signature: true, + }; + } + + return executable as unknown as Proposal; +} + type ConcreteCpmAsk = { currency?: string; budget?: { amount: number; currency: string }; @@ -2300,7 +2340,7 @@ async function deriveProductDiscoveryAccountScope( const directScope = deriveAccountScope(normalizedArgs, isProductDiscoveryTool(toolName)); if ( directScope - || (toolName !== 'refine_proposals' && toolName !== 'finalize_proposals' && toolName !== 'decline_proposals') + || (toolName !== 'refine_proposals' && toolName !== 'decline_proposals') ) { return directScope; } @@ -2315,8 +2355,6 @@ async function deriveProductDiscoveryAccountScope( .filter(isRecord) .map(decline => decline.proposal_id) .filter((id): id is string => typeof id === 'string') - : Array.isArray(originalArgs.proposal_ids) - ? originalArgs.proposal_ids.filter((id): id is string => typeof id === 'string') : []; const proposalSession = await getSession( sessionKeyFromArgs({}, ctx.mode, ctx.userId, ctx.moduleId, ctx.principal ?? 'anonymous'), @@ -4150,7 +4188,6 @@ const PRODUCT_DISCOVERY_TOOLS = new Set([ 'list_products', 'request_proposals', 'refine_proposals', - 'finalize_proposals', 'decline_proposals', ]); @@ -4169,7 +4206,6 @@ function productDiscoverySourceSchemaName(toolName: string): string | undefined case 'list_products': return 'list-products-request'; case 'request_proposals': return 'request-proposals-request'; case 'refine_proposals': return 'refine-proposals-request'; - case 'finalize_proposals': return 'finalize-proposals-request'; case 'decline_proposals': return 'decline-proposals-request'; default: return undefined; } @@ -4274,22 +4310,6 @@ export function normalizeProductDiscoveryArgs( : [], }; } - if (toolName === 'finalize_proposals') { - const { - buying_mode: _buyingMode, - refine: _refine, - proposal_ids: proposalIds, - ...rest - } = domainArgs; - return { - ...rest, - buying_mode: 'refine', - __compact_proposal_lifecycle: true, - refine: Array.isArray(proposalIds) - ? proposalIds.map(proposalId => ({ scope: 'proposal', proposal_id: proposalId, action: 'finalize' })) - : [], - }; - } if (toolName === 'decline_proposals') { const { declines, ...rest } = domainArgs; return { @@ -4424,19 +4444,6 @@ export function projectProductDiscoveryResult( }; } - const requestedIds = new Set( - toolName === 'refine_proposals' && Array.isArray(originalArgs.refinements) - ? originalArgs.refinements - .filter(isRecord) - .map(entry => entry.proposal_id) - .filter((id): id is string => typeof id === 'string') - : toolName === 'finalize_proposals' && Array.isArray(originalArgs.proposal_ids) - ? originalArgs.proposal_ids.filter((id): id is string => typeof id === 'string') - : [], - ); - const selected = proposals.filter(proposal => ( - typeof proposal.proposal_id === 'string' && requestedIds.has(proposal.proposal_id) - )); if (toolName === 'refine_proposals') { const sourceIds = Array.isArray(originalArgs.refinements) ? originalArgs.refinements @@ -4490,7 +4497,7 @@ export function projectProductDiscoveryResult( }), }; } - return { proposals: selected.map(outwardProposal) }; + return result; } /** Compact proposal operations address proposals by opaque ID under the @@ -4546,10 +4553,6 @@ export function validateProductDiscoveryAliasInput( 'adcp_version', 'adcp_major_version', 'idempotency_key', 'refinements', 'context_id', 'context', 'governance_context', 'push_notification_config', ]), - finalize_proposals: new Set([ - 'adcp_version', 'adcp_major_version', 'idempotency_key', 'proposal_ids', - 'context_id', 'context', 'governance_context', 'push_notification_config', - ]), decline_proposals: new Set([ 'adcp_version', 'adcp_major_version', 'idempotency_key', 'declines', 'opportunity', 'context_id', 'context', 'governance_context', 'push_notification_config', @@ -4564,7 +4567,6 @@ export function validateProductDiscoveryAliasInput( ( toolName === 'request_proposals' || toolName === 'refine_proposals' - || toolName === 'finalize_proposals' || toolName === 'decline_proposals' ) && args.idempotency_key == null @@ -4624,20 +4626,6 @@ export function validateProductDiscoveryAliasInput( } } } - if (toolName === 'finalize_proposals') { - if (!Array.isArray(args.proposal_ids) || args.proposal_ids.length === 0) { - return { message: 'proposal_ids must contain at least one proposal ID', field: 'proposal_ids' }; - } - if (args.proposal_ids.some(proposalId => typeof proposalId !== 'string' || proposalId.length === 0)) { - return { message: 'proposal_ids entries must be non-empty strings', field: 'proposal_ids' }; - } - if (new Set(args.proposal_ids).size !== args.proposal_ids.length) { - return { message: 'proposal_ids entries must be unique', field: 'proposal_ids' }; - } - if (args.proposal_ids.length > 25) { - return { message: 'proposal_ids exceeds max_atomic_finalize_batch_size (25)', field: 'proposal_ids' }; - } - } if (toolName === 'decline_proposals') { if (!Array.isArray(args.declines) || args.declines.length === 0) { return { message: 'declines must contain at least one proposal decline', field: 'declines' }; @@ -4680,7 +4668,6 @@ export function validateProductDiscoveryAliasInput( const LIST_PRODUCTS_INPUT_SCHEMA = loadProductDiscoveryInputSchema('list-products-request'); const REQUEST_PROPOSALS_INPUT_SCHEMA = loadProductDiscoveryInputSchema('request-proposals-request'); const REFINE_PROPOSALS_INPUT_SCHEMA = loadProductDiscoveryInputSchema('refine-proposals-request'); -const FINALIZE_PROPOSALS_INPUT_SCHEMA = loadProductDiscoveryInputSchema('finalize-proposals-request'); const DECLINE_PROPOSALS_INPUT_SCHEMA = loadProductDiscoveryInputSchema('decline-proposals-request'); const CREATE_MEDIA_BUY_OPPORTUNITY_INPUT_SCHEMA = { type: 'object', @@ -4758,25 +4745,18 @@ const TOOLS = [ }, { name: 'request_proposals', - description: 'Request one or more actionable media-plan proposals from a brief and optional listed product IDs. Proposal IDs connect later refinement, finalization, and purchase.', + description: 'Request executable media-plan proposals from a brief and optional listed product IDs. Each proposal_id is an exact snapshot that can be refined, purchased, or declined.', annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, execution: { taskSupport: 'optional' as const }, inputSchema: REQUEST_PROPOSALS_INPUT_SCHEMA, }, { name: 'refine_proposals', - description: 'Create revised drafts from one or more proposals without committing them. Each refinement is keyed by proposal_id.', + description: 'Create executable revisions from one or more proposals. Changed terms receive a new proposal_id; source snapshots remain immutable.', annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, execution: { taskSupport: 'optional' as const }, inputSchema: REFINE_PROPOSALS_INPUT_SCHEMA, }, - { - name: 'finalize_proposals', - description: 'Atomically commit one or more draft proposals to firm pricing and inventory holds. AdCP 3.2 replacement for exclusive get_products finalize refinements.', - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, - execution: { taskSupport: 'optional' as const }, - inputSchema: FINALIZE_PROPOSALS_INPUT_SCHEMA, - }, { name: 'decline_proposals', description: 'Terminally decline one or more immutable proposals. Repeated declines are semantically idempotent and declined proposals cannot be purchased.', @@ -5228,7 +5208,6 @@ export function productDiscoveryAliasToolDefinitions(): Array<(typeof TOOLS)[num tool.name === 'list_products' || tool.name === 'request_proposals' || tool.name === 'refine_proposals' - || tool.name === 'finalize_proposals' || tool.name === 'decline_proposals' ))); } @@ -5817,9 +5796,18 @@ async function handleGetProductsUnlocked( }); continue; } + if (immutableRefine && (proposal as unknown as Record).__executed === true) { + refinementApplied.push({ + scope: 'proposal', + proposal_id: op.proposal_id, + status: 'unable', + notes: 'Proposal was already executed and cannot be refined', + }); + continue; + } explicitlySelectedProposals.set(proposal.proposal_id, proposal); for (const allocation of proposal.allocations) includeIds.add(allocation.product_id); - if (proposalLifecycle(proposal).proposal_status === 'committed' && op.ask) { + if (!immutableRefine && proposalLifecycle(proposal).proposal_status === 'committed' && op.ask) { refinementApplied.push({ scope: 'proposal', proposal_id: op.proposal_id, @@ -5883,39 +5871,11 @@ async function handleGetProductsUnlocked( if (status === 'committed') { refinementApplied.push({ scope: 'proposal', proposal_id: op.proposal_id, status: 'applied', notes: 'Proposal already committed' }); } else { - const committed = { ...proposal } as Record & ProposalLifecycle; - committed.proposal_status = 'committed'; - (committed as Record).expires_at = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); - - const hasGuaranteed = proposal.allocations.some(alloc => { - const cp = getCatalog().find(c => c.product.product_id === alloc.product_id); - return cp?.product.delivery_type === 'guaranteed'; - }); - if (hasGuaranteed) { - const publisherCp = getCatalog().find(c => c.product.product_id === proposal.allocations[0].product_id); - const accountBrand = (req as unknown as Record).account as Record | undefined; - const boundBrandDomain = (proposal as unknown as Record).__brand_domain; - const brandDomain = ((accountBrand?.brand as Record)?.domain as string) - || (typeof boundBrandDomain === 'string' ? boundBrandDomain : undefined) - || 'advertiser.example'; - committed.insertion_order = { - io_id: `io_${randomUUID().replace(/-/g, '')}`, - terms: { - advertiser: brandDomain, - publisher: publisherCp?.publisherId || 'unknown', - total_budget: { - amount: proposal.total_budget_guidance?.recommended ?? 0, - currency: proposal.total_budget_guidance?.currency ?? 'USD', - }, - flight_start: new Date().toISOString(), - flight_end: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), - payment_terms: 'net_30', - }, - requires_signature: true, - }; - } - - const updatedProposal = committed as unknown as import('@adcp/sdk').Proposal; + const accountBrand = (req as unknown as Record).account as Record | undefined; + const boundBrandDomain = (proposal as unknown as Record).__brand_domain; + const brandDomain = ((accountBrand?.brand as Record)?.domain as string) + || (typeof boundBrandDomain === 'string' ? boundBrandDomain : undefined); + const updatedProposal = executableProposalSnapshot(proposal, brandDomain); stagedProposalCommits.set(op.proposal_id, updatedProposal); refinementApplied.push({ scope: 'proposal', proposal_id: op.proposal_id, status: 'applied', notes: 'Proposal finalized — pricing committed, inventory held for 24 hours' }); @@ -6006,9 +5966,7 @@ async function handleGetProductsUnlocked( } if (stagedProposalCommits.size > 0 || stagedProposalDeclines.size > 0) { // Publish the complete batch with one assignment only after every - // proposal has been resolved and every committed snapshot constructed. - // This is the training agent's transaction boundary: no request can - // observe a prefix of an atomic finalize_proposals batch. + // proposal has been resolved and every lifecycle update constructed. const prior = session.lastGetProductsContext?.proposals ?? []; const stagedUpdates = new Map([...stagedProposalCommits, ...stagedProposalDeclines]); const next = prior.map(proposal => stagedUpdates.get(proposal.proposal_id) ?? proposal); @@ -6114,17 +6072,20 @@ async function handleGetProductsUnlocked( .digest('hex') .slice(0, 24); const proposalId = `proposal_request_${digest}`; - return existingById.get(proposalId) ?? { + const snapshot = { ...proposal, proposal_id: proposalId, - proposal_status: 'draft' as const, - expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), ...(typeof requestBrand?.domain === 'string' && { __brand_domain: requestBrand.domain.toLowerCase() }), ...(typeof requestBrand?.brand_id === 'string' && { __brand_id: requestBrand.brand_id }), ...(typeof requestAccount?.account_id === 'string' && { __account_id: requestAccount.account_id }), ...(typeof requestOpportunity?.opportunity_id === 'string' && { __opportunity_id: requestOpportunity.opportunity_id }), - }; + } as unknown as Proposal; + return existingById.get(proposalId) + ?? executableProposalSnapshot( + snapshot, + typeof requestBrand?.domain === 'string' ? requestBrand.domain.toLowerCase() : undefined, + ); }); if (proposals.length === 0) { return { @@ -6154,14 +6115,18 @@ async function handleGetProductsUnlocked( const outcome = outcomesBySource.get(sourceId); if (!proposal || outcome?.status === 'unable') return []; const digest = createHash('sha256').update(`${key}:${sourceId}:${index}`).digest('hex').slice(0, 24); - return [{ + const revision = { ...proposal, proposal_id: `proposal_revision_${digest}`, - proposal_status: 'draft' as const, __source_proposal_id: sourceId, __refinement_outcome: outcome?.status === 'partial' ? 'partial' : 'revised', ...(outcome?.notes && { __refinement_notes: outcome.notes }), - }]; + } as unknown as Proposal; + const brandDomain = (proposal as unknown as Record).__brand_domain; + return [executableProposalSnapshot( + revision, + typeof brandDomain === 'string' ? brandDomain : undefined, + )]; }); } const canonicalFormatAdvisories = collectCanonicalFormatAdvisories(products); @@ -7176,10 +7141,10 @@ export async function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) const proposalId = (args as unknown as Record).proposal_id; if (typeof proposalId !== 'string') return handleCreateMediaBuyUnlocked(args, ctx); - // Proposal execution, finalization, and decline all transition the same - // principal-owned snapshot. Serialize them on the compact lifecycle session - // and persist before releasing so different idempotency keys cannot both - // execute one proposal. + // Proposal execution, legacy get_products finalization, and decline all + // transition the same principal-owned snapshot. Serialize them on the + // compact lifecycle session and persist before releasing so different + // idempotency keys cannot both execute one proposal. const sessionScope = sessionKeyFromArgs( {}, ctx.mode, @@ -7678,14 +7643,14 @@ async function handleCreateMediaBuyUnlocked(args: ToolArgs, ctx: TrainingContext const proposalStatus = proposalLifecycle(proposal).proposal_status; if (proposalStatus === 'draft' && !(isThreeZeroStoryboardCompat(ctx) && req.proposal_id === THREE_ZERO_LEGACY_PROPOSAL_ID)) { return { - errors: [{ code: 'PROPOSAL_NOT_COMMITTED', message: `Proposal "${req.proposal_id}" has draft status — finalize it first using finalize_proposals.` }] as TaskError[], + errors: [{ code: 'PROPOSAL_NOT_COMMITTED', message: `Proposal "${req.proposal_id}" has legacy draft status — finalize it through get_products before retrying.` }] as TaskError[], }; } // Enforce proposal expiry if (proposal.expires_at && new Date(proposal.expires_at) < new Date()) { return { - errors: [{ code: 'PROPOSAL_EXPIRED', message: `Proposal "${req.proposal_id}" expired at ${proposal.expires_at}. Request and finalize a fresh proposal before retrying.` }] as TaskError[], + errors: [{ code: 'PROPOSAL_EXPIRED', message: `Proposal "${req.proposal_id}" expired at ${proposal.expires_at}. Request a fresh proposal before retrying.` }] as TaskError[], }; } @@ -9738,7 +9703,6 @@ export async function handleGetAdcpCapabilities(args: ToolArgs, ctx: TrainingCon buying_modes: wholesaleProfile.productWholesale ? ['brief', 'wholesale', 'refine'] : ['brief', 'refine'], ...(supportsGetProductsRejected(servedAdcpVersion) && { product_discovery_tools: [...PRODUCT_DISCOVERY_TOOLS], - max_atomic_finalize_batch_size: 25, }), supports_proposals: true, features: { @@ -11631,7 +11595,6 @@ const HANDLER_MAP: Record = { list_products: handleGetProducts, request_proposals: handleGetProducts, refine_proposals: handleGetProducts, - finalize_proposals: handleGetProducts, decline_proposals: handleGetProducts, list_creative_formats: handleListCreativeFormats, validate_input: handleValidateInput, diff --git a/server/src/training-agent/tenants/router.ts b/server/src/training-agent/tenants/router.ts index 9a29e4caf5..7230e06e14 100644 --- a/server/src/training-agent/tenants/router.ts +++ b/server/src/training-agent/tenants/router.ts @@ -135,7 +135,6 @@ const PRODUCT_DISCOVERY_TOOL_NAMES = [ 'list_products', 'request_proposals', 'refine_proposals', - 'finalize_proposals', 'decline_proposals', ] as const; diff --git a/server/src/training-agent/tenants/tenant-smoke.test.ts b/server/src/training-agent/tenants/tenant-smoke.test.ts index 89771ee61b..dca6e7259e 100644 --- a/server/src/training-agent/tenants/tenant-smoke.test.ts +++ b/server/src/training-agent/tenants/tenant-smoke.test.ts @@ -1544,7 +1544,6 @@ describe('tenant routing smoke', () => { 'list_products', 'request_proposals', 'refine_proposals', - 'finalize_proposals', 'decline_proposals', ])); const listAlias = listBody.result?.tools?.find(tool => tool.name === 'list_products'); diff --git a/server/src/training-agent/tenants/tool-catalog.ts b/server/src/training-agent/tenants/tool-catalog.ts index 7e1b6e100e..9a8082707b 100644 --- a/server/src/training-agent/tenants/tool-catalog.ts +++ b/server/src/training-agent/tenants/tool-catalog.ts @@ -37,7 +37,6 @@ export const TOOL_CATALOG: Readonly> = { list_products: ['sales'], request_proposals: ['sales'], refine_proposals: ['sales'], - finalize_proposals: ['sales'], decline_proposals: ['sales'], create_media_buy: ['sales'], update_media_buy: ['sales'], @@ -140,7 +139,6 @@ export function toolsForTenant( tool === 'list_products' || tool === 'request_proposals' || tool === 'refine_proposals' - || tool === 'finalize_proposals' || tool === 'decline_proposals' ) return false; if (tool === 'validate_input' || tool === 'list_transformers') return false; diff --git a/server/src/training-agent/webhooks.ts b/server/src/training-agent/webhooks.ts index 7ee373a78c..7ad2a7efc3 100644 --- a/server/src/training-agent/webhooks.ts +++ b/server/src/training-agent/webhooks.ts @@ -37,7 +37,7 @@ const logger = createLogger('training-agent-webhooks'); * Keep in sync with `static/schemas/source/core/mcp-webhook-payload.json`. */ export type WebhookTaskType = | 'create_media_buy' | 'update_media_buy' | 'sync_creatives' | 'build_creative' - | 'get_products' | 'request_proposals' | 'refine_proposals' | 'finalize_proposals' | 'decline_proposals' + | 'get_products' | 'request_proposals' | 'refine_proposals' | 'decline_proposals' | 'activate_signal' | 'get_signals' | 'create_property_list' | 'update_property_list' | 'get_property_list' | 'list_property_lists' | 'delete_property_list' | 'sync_accounts' @@ -49,7 +49,6 @@ export const TOOL_TO_TASK_TYPE = { get_products: 'get_products', request_proposals: 'request_proposals', refine_proposals: 'refine_proposals', - finalize_proposals: 'finalize_proposals', decline_proposals: 'decline_proposals', create_media_buy: 'create_media_buy', update_media_buy: 'update_media_buy', @@ -90,7 +89,6 @@ export const TOOL_TO_PROTOCOL: Readonly { 'list_products', 'request_proposals', 'refine_proposals', - 'finalize_proposals', 'decline_proposals', ]; for (const tool of splitTools) { diff --git a/server/tests/integration/training-agent-webhooks.test.ts b/server/tests/integration/training-agent-webhooks.test.ts index 1522e3c31c..5424933002 100644 --- a/server/tests/integration/training-agent-webhooks.test.ts +++ b/server/tests/integration/training-agent-webhooks.test.ts @@ -195,7 +195,7 @@ describe('Training Agent webhook emission', () => { deliveries.push(delivery); res.writeHead(200); res.end(); - if (deliveries.length === 3) resolveDeliveries?.(); + if (deliveries.length === 2) resolveDeliveries?.(); }); const addr = srv.address() as AddressInfo; const webhookUrl = `http://127.0.0.1:${addr.port}/hook/split-proposals`; @@ -237,30 +237,20 @@ describe('Training Agent webhook emission', () => { }); const refined = structuredToolResult(refinedResponse); expect(refined).not.toHaveProperty('adcp_error'); - const revision = ((refined.results as Array>)[0].proposal) as Record; - - const finalizedResponse = await call('finalize_proposals', { - idempotency_key: `split-finalize-${randomUUID()}`, - proposal_ids: [revision.proposal_id], - push_notification_config: callback('op_finalize_proposals'), - }); - expect(structuredToolResult(finalizedResponse)).not.toHaveProperty('adcp_error'); await Promise.race([ delivered, new Promise((_, reject) => setTimeout(() => reject(new Error('split lifecycle webhooks never arrived')), 10_000)), ]); - expect(deliveries).toHaveLength(3); + expect(deliveries).toHaveLength(2); const bodies = deliveries.map(delivery => JSON.parse(delivery.body) as Record); expect(bodies.map(body => body.task_type)).toEqual([ 'request_proposals', 'refine_proposals', - 'finalize_proposals', ]); expect(bodies.map(body => body.operation_id)).toEqual([ 'op_request_proposals', 'op_refine_proposals', - 'op_finalize_proposals', ]); expect(bodies.every(body => body.token === 'split-callback-token-1234')).toBe(true); const results = bodies.map(body => body.result as Record); @@ -269,9 +259,6 @@ describe('Training Agent webhook emission', () => { expect(results[1]).toMatchObject({ results: expect.any(Array), products: expect.any(Array) }); expect(results[1]).not.toHaveProperty('proposals'); expect(results[1]).not.toHaveProperty('refinement_applied'); - expect(results[2]).toMatchObject({ proposals: expect.any(Array) }); - expect(results[2]).not.toHaveProperty('products'); - expect(results[2]).not.toHaveProperty('refinement_applied'); } finally { if (srv) { srv.closeAllConnections?.(); @@ -309,7 +296,7 @@ describe('Training Agent webhook emission', () => { principal: 'webhook-test-principal', }); maybeEmitCompletionWebhook({ - toolName: 'finalize_proposals', + toolName: 'refine_proposals', args: { push_notification_config: { url: `http://127.0.0.1:${addr.port}/hook/hmac`, @@ -320,7 +307,7 @@ describe('Training Agent webhook emission', () => { }, }, }, - response: { proposals: [] }, + response: { results: [], products: [] }, principal: 'webhook-test-principal', }); await Promise.race([ diff --git a/server/tests/unit/idempotency.test.ts b/server/tests/unit/idempotency.test.ts index 8a590e681c..79b0bc6dcf 100644 --- a/server/tests/unit/idempotency.test.ts +++ b/server/tests/unit/idempotency.test.ts @@ -72,7 +72,7 @@ describe('idempotency facade', () => { it('covers specific mutating tools explicitly', () => { for (const name of [ 'create_media_buy', 'update_media_buy', 'sync_audiences', - 'request_proposals', 'refine_proposals', 'finalize_proposals', 'decline_proposals', + 'request_proposals', 'refine_proposals', 'decline_proposals', 'si_initiate_session', 'si_send_message', 'acquire_rights', 'update_rights', 'creative_approval', ]) { diff --git a/server/tests/unit/product-discovery-schema-parity.test.ts b/server/tests/unit/product-discovery-schema-parity.test.ts index 94631a3a88..ace630e497 100644 --- a/server/tests/unit/product-discovery-schema-parity.test.ts +++ b/server/tests/unit/product-discovery-schema-parity.test.ts @@ -29,7 +29,6 @@ describe('product discovery MCP schema parity', () => { ['list_products', 'list-products-request'], ['request_proposals', 'request-proposals-request'], ['refine_proposals', 'refine-proposals-request'], - ['finalize_proposals', 'finalize-proposals-request'], ['decline_proposals', 'decline-proposals-request'], ] as const) { const runtime = tools.get(toolName)!; diff --git a/server/tests/unit/training-agent-webhook-contract.test.ts b/server/tests/unit/training-agent-webhook-contract.test.ts index d4ffaa4a38..fdb34cd38d 100644 --- a/server/tests/unit/training-agent-webhook-contract.test.ts +++ b/server/tests/unit/training-agent-webhook-contract.test.ts @@ -20,7 +20,6 @@ describe('training-agent completion webhook contract', () => { get_products: 'get_products', request_proposals: 'request_proposals', refine_proposals: 'refine_proposals', - finalize_proposals: 'finalize_proposals', decline_proposals: 'decline_proposals', build_creative: 'build_creative', update_rights: 'update_rights', @@ -29,7 +28,6 @@ describe('training-agent completion webhook contract', () => { get_products: 'media-buy', request_proposals: 'media-buy', refine_proposals: 'media-buy', - finalize_proposals: 'media-buy', decline_proposals: 'media-buy', build_creative: 'creative', update_rights: 'brand', diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index 2c635d867d..cfbc73fd5b 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -1382,7 +1382,6 @@ describe('createTrainingAgentServer', () => { expect(toolNames).toContain('list_products'); expect(toolNames).toContain('request_proposals'); expect(toolNames).toContain('refine_proposals'); - expect(toolNames).toContain('finalize_proposals'); expect(toolNames).toContain('decline_proposals'); expect(toolNames).toContain('list_creative_formats'); expect(toolNames).toContain('create_media_buy'); @@ -1434,7 +1433,7 @@ describe('createTrainingAgentServer', () => { expect(toolNames).toContain('update_collection_list'); expect(toolNames).toContain('list_collection_lists'); expect(toolNames).toContain('delete_collection_list'); - expect(toolNames).toHaveLength(56); + expect(toolNames).toHaveLength(55); const validateInput = tools.find(t => t.name === 'validate_input'); expect(validateInput?.inputSchema?.properties?.targets?.maxItems).toBe(50); @@ -12796,7 +12795,7 @@ describe('proposal lifecycle', () => { }); }); - it('connects the compact request, refine, finalize, and purchase lifecycle', async () => { + it('connects the compact request, refine, and purchase lifecycle', async () => { const server = createTrainingAgentServer(DEFAULT_CTX); const lifecycleOpportunity = { opportunity_id: 'opp-compact-purchase-2027', @@ -12812,12 +12811,7 @@ describe('proposal lifecycle', () => { expect(requested).not.toHaveProperty('pagination'); expect(requested).not.toHaveProperty('refinement_applied'); const source = (requested.proposals as Array>)[0]; - expect(source).toMatchObject({ proposal_status: 'draft' }); - - const { isError: atomicFailure } = await simulateCallTool(server, 'finalize_proposals', { - proposal_ids: [source.proposal_id, 'proposal-not-visible-to-caller'], - }); - expect(atomicFailure).toBe(true); + expect(source).toMatchObject({ proposal_status: 'committed' }); const { result: refined, isError: refineError } = await simulateCallTool(server, 'refine_proposals', { refinements: [{ @@ -12833,7 +12827,7 @@ describe('proposal lifecycle', () => { expect(refinement).toMatchObject({ source_proposal_id: source.proposal_id, outcome: 'partial', - proposal: { proposal_status: 'draft' }, + proposal: { proposal_status: 'committed' }, }); const revision = refinement.proposal as Record; expect(revision.proposal_id).not.toBe(source.proposal_id); @@ -12842,11 +12836,7 @@ describe('proposal lifecycle', () => { outcome: 'unable', }); - const { result: finalized, isError: finalizeError } = await simulateCallTool(server, 'finalize_proposals', { - proposal_ids: [revision.proposal_id], - }); - expect(finalizeError).toBeFalsy(); - const committed = (finalized.proposals as Array>)[0]; + const committed = revision; expect(committed).toMatchObject({ proposal_id: revision.proposal_id, proposal_status: 'committed', @@ -12917,13 +12907,16 @@ describe('proposal lifecycle', () => { replayed: true, }); - const finalizedAgain = await simulateCallTool(server, 'finalize_proposals', { - proposal_ids: [committed.proposal_id], + const refineAfterExecution = await simulateCallTool(server, 'refine_proposals', { + refinements: [{ + proposal_id: committed.proposal_id, + instructions: 'Mint another buyable revision after executing this snapshot.', + }], + }); + expect(refineAfterExecution.isError).toBeFalsy(); + expect(refineAfterExecution.result).toMatchObject({ + results: [{ source_proposal_id: committed.proposal_id, outcome: 'unable' }], }); - expect(finalizedAgain.isError).toBeFalsy(); - const finalizedAgainProposal = (finalizedAgain.result.proposals as Array>)[0]; - expect(finalizedAgainProposal).not.toHaveProperty('__executed'); - expect(finalizedAgainProposal).not.toHaveProperty('__opportunity_update'); const secondExecution = await simulateCallTool(server, 'create_media_buy', { ...createArgs, @@ -12947,11 +12940,7 @@ describe('proposal lifecycle', () => { brand: account.brand, brief: 'social engagement display', }); - const draft = (requested.proposals as Array>)[0]; - const { result: finalized } = await simulateCallTool(server, 'finalize_proposals', { - proposal_ids: [draft.proposal_id], - }); - const committed = (finalized.proposals as Array>)[0]; + const committed = (requested.proposals as Array>)[0]; const base = { account, brand: account.brand, @@ -12988,11 +12977,7 @@ describe('proposal lifecycle', () => { brand: account.brand, brief: 'social engagement display', }); - const draft = (requested.proposals as Array>)[0]; - const { result: finalized } = await simulateCallTool(server, 'finalize_proposals', { - proposal_ids: [draft.proposal_id], - }); - const committed = (finalized.proposals as Array>)[0]; + const committed = (requested.proposals as Array>)[0]; const [create, decline] = await Promise.all([ simulateCallTool(server, 'create_media_buy', { idempotency_key: `test-${randomUUID()}`, @@ -13036,11 +13021,11 @@ describe('proposal lifecycle', () => { opportunity, }); expect(requestError).toBeFalsy(); - const draft = (requested.proposals as Array>)[0]; + const source = (requested.proposals as Array>)[0]; const { result: refined, isError: refineError } = await simulateCallTool(server, 'refine_proposals', { refinements: [{ - proposal_id: draft.proposal_id, + proposal_id: source.proposal_id, instructions: 'Prefer social inventory without changing the planning cycle.', }], }); @@ -13055,11 +13040,7 @@ describe('proposal lifecycle', () => { expect(storedRevision?.__opportunity_id).toBe(opportunity.opportunity_id); }); - const { result: finalized, isError: finalizeError } = await simulateCallTool(server, 'finalize_proposals', { - proposal_ids: [revision.proposal_id], - }); - expect(finalizeError).toBeFalsy(); - const committed = (finalized.proposals as Array>)[0]; + const committed = revision; const mismatchedOpportunity = await simulateCallTool(server, 'decline_proposals', { declines: [{ proposal_id: committed.proposal_id, reason: 'inventory_fit' }], @@ -13140,12 +13121,6 @@ describe('proposal lifecycle', () => { }); }); - const finalizeAfterDecline = await simulateCallTool(server, 'finalize_proposals', { - proposal_ids: [committed.proposal_id], - }); - expect(finalizeAfterDecline.isError).toBe(true); - expect(finalizeAfterDecline.result).toMatchObject({ code: 'INVALID_STATE' }); - const refineAfterDecline = await simulateCallTool(server, 'refine_proposals', { refinements: [{ proposal_id: committed.proposal_id, @@ -13255,42 +13230,11 @@ describe('proposal lifecycle', () => { expect(revision).toMatchObject({ source_proposal_id: source.proposal_id, outcome: 'revised', - proposal: { proposal_status: 'draft' }, + proposal: { proposal_status: 'committed' }, }); expect(revision).not.toHaveProperty('notes'); }); - it('commits a statusless backward-compatible proposal before returning it from finalize_proposals', async () => { - const server = createTrainingAgentServer(DEFAULT_CTX); - const { result: requested, isError: requestError } = await simulateCallTool(server, 'request_proposals', { - brand: account.brand, - brief: 'social engagement display', - }); - expect(requestError).toBeFalsy(); - const source = (requested.proposals as Array>)[0]; - const compactSessionKey = sessionKeyFromArgs({}, DEFAULT_CTX.mode, undefined, undefined, 'anonymous'); - await runWithSessionContext(async () => { - const session = await getSession(compactSessionKey); - const stored = session.lastGetProductsContext?.proposals?.find( - proposal => proposal.proposal_id === source.proposal_id, - ) as (Record | undefined); - expect(stored).toBeDefined(); - delete stored!.proposal_status; - delete stored!.expires_at; - await flushDirtySessions(); - }); - - const { result: finalized, isError: finalizeError } = await simulateCallTool(server, 'finalize_proposals', { - proposal_ids: [source.proposal_id], - }); - expect(finalizeError).toBeFalsy(); - expect((finalized.proposals as Array>)[0]).toMatchObject({ - proposal_id: source.proposal_id, - proposal_status: 'committed', - expires_at: expect.any(String), - }); - }); - it('binds compact proposals to both the seller account and full BrandKey', async () => { const server = createTrainingAgentServer(DEFAULT_CTX); const originalBrand = { domain: 'proposal-house.example', brand_id: 'alpha' }; @@ -13301,12 +13245,7 @@ describe('proposal lifecycle', () => { brief: 'social engagement display', }); expect(requestError).toBeFalsy(); - const draft = (requested.proposals as Array>)[0]; - const { result: finalized, isError: finalizeError } = await simulateCallTool(server, 'finalize_proposals', { - proposal_ids: [draft.proposal_id], - }); - expect(finalizeError).toBeFalsy(); - const committed = (finalized.proposals as Array>)[0]; + const committed = (requested.proposals as Array>)[0]; const purchase = (billingAccount: string, brand: typeof originalBrand) => simulateCallTool( server, @@ -13339,41 +13278,6 @@ describe('proposal lifecycle', () => { expect(accepted.result.media_buy_id).toEqual(expect.any(String)); }); - it('rejects an expired draft before atomically finalizing any proposal in the batch', async () => { - const server = createTrainingAgentServer(DEFAULT_CTX); - const requestDraft = async (brief: string) => { - const { result, isError } = await simulateCallTool(server, 'request_proposals', { - brand: account.brand, - brief, - }); - expect(isError).toBeFalsy(); - return (result.proposals as Array>)[0]; - }; - const first = await requestDraft('social engagement display'); - const second = await requestDraft('cross-channel news display'); - const compactSessionKey = sessionKeyFromArgs({}, DEFAULT_CTX.mode, undefined, undefined, 'anonymous'); - await runWithSessionContext(async () => { - const session = await getSession(compactSessionKey); - const expired = session.lastGetProductsContext?.proposals?.find( - proposal => proposal.proposal_id === first.proposal_id, - ); - expect(expired).toBeDefined(); - (expired as unknown as Record).expires_at = '2020-01-01T00:00:00Z'; - await flushDirtySessions(); - }); - - const rejected = await simulateCallTool(server, 'finalize_proposals', { - proposal_ids: [second.proposal_id, first.proposal_id], - }); - expect(rejected).toMatchObject({ isError: true, result: { code: 'PROPOSAL_EXPIRED' } }); - - const session = await getSession(compactSessionKey); - const stillDraft = session.lastGetProductsContext?.proposals?.find( - proposal => proposal.proposal_id === second.proposal_id, - ); - expect(stillDraft?.proposal_status).toBe('draft'); - }); - async function getProductsWithProposals() { const server = createTrainingAgentServer(DEFAULT_CTX); const { result } = await simulateCallTool(server, 'get_products', { diff --git a/static/schemas/source/core/account-authorization.json b/static/schemas/source/core/account-authorization.json index e1e5539673..0cff0698fb 100644 --- a/static/schemas/source/core/account-authorization.json +++ b/static/schemas/source/core/account-authorization.json @@ -44,7 +44,7 @@ }, "read_only": { "type": "boolean", - "description": "Convenience flag. When true, the caller is permitted only non-mutating tasks. Sellers MUST reject any mutation from a read-only caller with READ_ONLY_SCOPE. For the AdCP 3.2 product split, read-only permits list_products but rejects request_proposals, refine_proposals, finalize_proposals, and decline_proposals; a legacy get_products call is permitted only when the seller can guarantee the selected arm is a synchronous side-effect-free read. Sellers MAY omit this field; omission is equivalent to `false`. Callers MUST NOT infer read-only from `allowed_tasks` alone — the seller MUST set this explicitly when it applies.", + "description": "Convenience flag. When true, the caller is permitted only non-mutating tasks. Sellers MUST reject any mutation from a read-only caller with READ_ONLY_SCOPE. For the AdCP 3.2 product split, read-only permits list_products but rejects request_proposals, refine_proposals, and decline_proposals; a legacy get_products call is permitted only when the seller can guarantee the selected arm is a synchronous side-effect-free read. Sellers MAY omit this field; omission is equivalent to `false`. Callers MUST NOT infer read-only from `allowed_tasks` alone — the seller MUST set this explicitly when it applies.", "default": false } }, diff --git a/static/schemas/source/core/async-response-data.json b/static/schemas/source/core/async-response-data.json index 2e32cebe0e..11c2d7c6ce 100644 --- a/static/schemas/source/core/async-response-data.json +++ b/static/schemas/source/core/async-response-data.json @@ -39,11 +39,6 @@ "description": "Terminal response for refine_proposals", "$ref": "/schemas/media-buy/refine-proposals-response.json" }, - { - "title": "FinalizeProposalsResponse", - "description": "Terminal response for finalize_proposals", - "$ref": "/schemas/media-buy/finalize-proposals-response.json" - }, { "title": "DeclineProposalsResponse", "description": "Terminal response for decline_proposals", diff --git a/static/schemas/source/core/proposal.json b/static/schemas/source/core/proposal.json index 1000f1cbcc..490b3971ba 100644 --- a/static/schemas/source/core/proposal.json +++ b/static/schemas/source/core/proposal.json @@ -2,12 +2,12 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/proposal.json", "title": "Proposal", - "description": "A proposed media-plan snapshot with fixed or seller-optimized budget allocation across products. In the AdCP 3.2 split proposal lifecycle, refinement or changed commercial terms produce a new proposal_id while lifecycle and buyer-disposition state may progress on the same snapshot. Legacy get_products proposal behavior remains compatible throughout 3.x. Committed proposals execute through create_media_buy(proposal_id), drafts commit through finalize_proposals, and proposals declined through the split lifecycle cannot execute.", + "description": "A proposed media-plan snapshot with fixed or seller-optimized budget allocation across products. In the AdCP 3.2 split proposal lifecycle, request_proposals and refine_proposals return executable committed snapshots; changed commercial terms produce a new proposal_id while buyer-disposition state may progress on the same snapshot. Legacy get_products draft/finalize behavior remains compatible throughout 3.x. Committed proposals execute through create_media_buy(proposal_id), and proposals declined through the split lifecycle cannot execute.", "type": "object", "properties": { "proposal_id": { "type": "string", - "description": "Unique identifier for this commercial snapshot. Used to refine or decline a proposal, finalize a draft, and execute a committed proposal via create_media_buy. For proposals issued by the AdCP 3.2 split tasks, a seller MUST issue a new ID whenever refinement changes terms.", + "description": "Unique identifier for this commercial snapshot. Used to refine or decline a proposal and to execute it via create_media_buy. For proposals issued by the AdCP 3.2 split tasks, a seller MUST issue a new ID whenever refinement changes terms.", "x-entity": "proposal", "maxLength": 255 }, @@ -43,12 +43,12 @@ }, "proposal_status": { "$ref": "/schemas/enums/proposal-status.json", - "description": "Lifecycle status of this proposal and the per-proposal source of truth for whether finalization is required before create_media_buy. When absent, the proposal is ready to buy (backward compatible). 'draft' means indicative pricing — finalize via refine before purchasing. 'committed' means firm pricing with inventory reserved until expires_at and executable via create_media_buy." + "description": "Lifecycle status of this proposal. AdCP 3.2 split proposal tasks return 'committed' snapshots that are immediately executable via create_media_buy. Legacy get_products may still return 'draft' proposals requiring its 3.x finalize refinement. When absent, the proposal is ready to buy for backward compatibility." }, "expires_at": { "type": "string", "format": "date-time", - "description": "When this proposal expires and can no longer be executed. For draft proposals, indicates when indicative pricing becomes stale. For committed proposals, indicates when the inventory hold lapses — the buyer must call create_media_buy before this time." + "description": "When this proposal expires and can no longer be executed. For draft proposals, indicates when indicative pricing becomes stale. For committed proposals, this is the deadline for create_media_buy. Commitment makes the terms executable but does not by itself assert an inventory hold; when the proposal terms include a hold, it lasts no later than this timestamp." }, "insertion_order": { "$ref": "/schemas/core/insertion-order.json", diff --git a/static/schemas/source/core/x-entity-types.json b/static/schemas/source/core/x-entity-types.json index 6a5c082869..673257cca9 100644 --- a/static/schemas/source/core/x-entity-types.json +++ b/static/schemas/source/core/x-entity-types.json @@ -58,7 +58,7 @@ "media_buy": "A media buy / campaign. `media_buy_id` across media-buy/* schemas.", "package": "A line item within a media buy. `package_id` across media-buy/* schemas.", "product": "A seller inventory product. `product_id` across media-buy/* and core/product.json.", - "proposal": "A seller-issued immutable draft or committed media-plan snapshot. `proposal_id` is returned by request_proposals or refine_proposals and consumed by refine_proposals, finalize_proposals, decline_proposals, and create_media_buy. Scoped to the issuing seller and authenticated principal.", + "proposal": "A seller-issued immutable media-plan snapshot. `proposal_id` is returned by request_proposals or refine_proposals and consumed by refine_proposals, decline_proposals, and create_media_buy. Scoped to the issuing seller and authenticated principal.", "opportunity": "A buyer-assigned planning cycle spanning proposal request, decline, and media-buy creation. `opportunity_id` is scoped to the seller and account and is not proposal identity.", "placement": "A public ad placement within a publisher namespace. Identified by the composite of `publisher_domain` plus `placement_id` in core/placement-ref.json and core/placement.json; legacy single-publisher contexts may omit publisher_domain but the identifier is still placement-scoped, not product-scoped.", "product_pricing_option": "A pricing tier on a seller's inventory product (CPM / CPC / CPCV / etc). `pricing_option_id` inside `core/package.json` and `media-buy/package-request.json`. Scoped to the seller's product rate card — not interchangeable with `vendor_pricing_option`.", diff --git a/static/schemas/source/creative/list-creative-formats-request.json b/static/schemas/source/creative/list-creative-formats-request.json index 4d6ce9245d..d1dcad514f 100644 --- a/static/schemas/source/creative/list-creative-formats-request.json +++ b/static/schemas/source/creative/list-creative-formats-request.json @@ -3,6 +3,7 @@ "$id": "/schemas/creative/list-creative-formats-request.json", "title": "List Creative Formats Request (Creative Agent)", "deprecated": true, + "x-deprecated-in": "3.2.0", "description": "**DEPRECATED in 3.2.** Compatibility request for legacy named-format discovery. Creative agents advertise canonical operation capabilities on get_adcp_capabilities `creative.supported_formats[]`.", "type": "object", "allOf": [ diff --git a/static/schemas/source/enums/error-code.json b/static/schemas/source/enums/error-code.json index 713ad5eaee..42c6343cc5 100644 --- a/static/schemas/source/enums/error-code.json +++ b/static/schemas/source/enums/error-code.json @@ -116,7 +116,7 @@ "POLICY_VIOLATION": "Request violates the seller's content or advertising policies. Recovery: correctable (review policy requirements in the error details).", "PRODUCT_NOT_FOUND": "One or more referenced product IDs are unknown or expired. Recovery: correctable (remove invalid IDs and retry, or re-discover with get_products).", "PRODUCT_UNAVAILABLE": "The requested product is sold out or no longer available. Recovery: correctable (choose a different product).", - "PROPOSAL_EXPIRED": "A referenced proposal ID has passed its expires_at timestamp. Recovery: correctable (re-discover with get_products to get a fresh proposal).", + "PROPOSAL_EXPIRED": "A referenced proposal ID has passed its expires_at timestamp. Recovery: correctable (call request_proposals to get a fresh executable proposal, or re-discover through the legacy get_products facade).", "BUDGET_TOO_LOW": "Budget is below the seller's minimum. Recovery: correctable (increase budget or check capabilities.media_buy.limits).", "CREATIVE_REJECTED": "Creative failed content policy review. For deadline violations, see CREATIVE_DEADLINE_EXCEEDED. Recovery: correctable (revise the creative per the seller's advertising_policies).", "CREATIVE_LOCALE_NOT_ACCEPTED": "A creative bound to a locale-constrained product format has no materialized variant matching locale_policy.accepted_language_ranges, lacks protocol-declared locale topology, or uses serve_default with a seller-ineligible default variant. Seller ranges use RFC 4647 Basic Filtering and are applied independently for every placement where the assignment may serve, before buyer Lookup, locale_fallbacks, or default selection. Distinct from CREATIVE_REJECTED because this is a mechanically discoverable assignment-eligibility mismatch, not subjective content review. error.field SHOULD point to the offending creative or assignment; error.details SHOULD include format_option_id when present, accepted_language_ranges, available_variant_locales, and placement identity when applicable. Recovery: correctable (supply or assign a matching materialized locale variant, narrow placement scope, choose a compatible format option, or change an ineligible serve_default).", @@ -156,8 +156,8 @@ "SESSION_TERMINATED": "SI session has already been terminated and cannot accept further messages. Recovery: correctable (initiate a new session via si_initiate_session).", "VALIDATION_ERROR": "Request contains invalid field values or violates business rules beyond schema validation. Recovery: correctable (review error details and fix field values).", "PRODUCT_EXPIRED": "One or more referenced products have passed their expires_at timestamp and are no longer available for purchase. Recovery: correctable (re-discover with get_products to find current inventory).", - "PROPOSAL_NOT_COMMITTED": "The referenced proposal has proposal_status 'draft' and cannot be used to create a media buy. Recovery: correctable (call finalize_proposals first; the legacy get_products finalize form remains supported throughout 3.x).", - "PROPOSAL_NOT_FOUND": "The referenced proposal_id is not recognized by the seller — never finalized, belongs to a different tenant, or evicted from the seller's session cache before consumption. Distinct from `PROPOSAL_EXPIRED` (a known proposal whose `expires_at` window has passed) and `PROPOSAL_NOT_COMMITTED` (a known proposal still in `draft`). Recovery: correctable (request and finalize a current proposal, then retry create_media_buy).", + "PROPOSAL_NOT_COMMITTED": "The referenced legacy get_products proposal has proposal_status 'draft' and cannot be used to create a media buy. Recovery: correctable (finalize it through get_products first). AdCP 3.2 request_proposals and refine_proposals return executable committed snapshots.", + "PROPOSAL_NOT_FOUND": "The referenced proposal_id is not recognized by the seller — it belongs to a different tenant, was never issued, or was evicted from the seller's session cache before consumption. Distinct from `PROPOSAL_EXPIRED` (a known proposal whose `expires_at` window has passed) and `PROPOSAL_NOT_COMMITTED` (a known legacy get_products proposal still in `draft`). Recovery: correctable (request a fresh executable proposal, then retry create_media_buy).", "MULTI_FINALIZE_UNSUPPORTED": "Returned by sellers that cannot guarantee atomic commit across multiple proposals in a single `get_products` call (multiple `action: 'finalize'` entries in `refine[]` targeting different `proposal_id` values). The buyer's intent — atomic multi-proposal finalize — is structurally well-formed and per spec atomic, but this seller's downstream stack cannot satisfy the atomicity guarantee (e.g., the proposals route to two different ad servers with no 2PC). More specific than `INVALID_REQUEST` so buyers can distinguish 'this seller doesn't support multi-finalize' from 'the request itself is malformed'. Recovery: correctable (sequence the finalize calls one-`proposal_id`-per-call; there is no capability flag for multi-finalize support, so a successful first-attempt is the only positive discovery signal). See [refinement guide § Finalize is exclusive](/docs/media-buy/product-discovery/refinement#finalize-is-exclusive-within-refine).", "IO_REQUIRED": "The committed proposal requires a signed insertion order but no io_acceptance was provided. Recovery: correctable (review the proposal's insertion_order, accept terms, and include io_acceptance on create_media_buy).", "TERMS_REJECTED": "Buyer-proposed measurement_terms were rejected by the seller. The error details SHOULD identify which specific term was rejected and the seller's acceptable range or supported vendors. Recovery: correctable (adjust the proposed terms and retry, or omit measurement_terms to accept the product's defaults).", @@ -252,7 +252,7 @@ }, "PROPOSAL_EXPIRED": { "recovery": "correctable", - "suggestion": "re-discover with get_products to get a fresh proposal" + "suggestion": "call request_proposals for a fresh executable proposal, or re-discover through legacy get_products" }, "BUDGET_TOO_LOW": { "recovery": "correctable", @@ -412,11 +412,11 @@ }, "PROPOSAL_NOT_COMMITTED": { "recovery": "correctable", - "suggestion": "finalize the proposal first using finalize_proposals" + "suggestion": "finalize the legacy draft through get_products, or request a fresh executable proposal" }, "PROPOSAL_NOT_FOUND": { "recovery": "correctable", - "suggestion": "request and finalize a current proposal_id, then retry" + "suggestion": "request a fresh executable proposal, then retry" }, "MULTI_FINALIZE_UNSUPPORTED": { "recovery": "correctable", diff --git a/static/schemas/source/enums/proposal-status.json b/static/schemas/source/enums/proposal-status.json index f68ba2da4e..f3abc730ca 100644 --- a/static/schemas/source/enums/proposal-status.json +++ b/static/schemas/source/enums/proposal-status.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/enums/proposal-status.json", "title": "Proposal Status", - "description": "Lifecycle status of a proposal. This is the per-proposal signal for whether finalization is required before create_media_buy. Absent means the proposal is ready to buy (backward compatible with pre-v3.1 proposals). 'draft' indicates indicative pricing that must be finalized before purchase. 'committed' indicates firm pricing with inventory reserved until expires_at.", + "description": "Lifecycle status of a proposal. This is the per-proposal signal for whether finalization is required before create_media_buy. Absent means the proposal is ready to buy (backward compatible with pre-v3.1 proposals). 'draft' indicates indicative pricing that must be finalized before purchase. 'committed' indicates firm terms that can be executed until expires_at; the status alone does not assert an inventory hold. Legacy get_products finalization requests a hold, while split 3.2 proposal tasks may return executable alternatives without reserving every alternative.", "type": "string", "enum": [ "draft", @@ -10,6 +10,6 @@ ], "enumDescriptions": { "draft": "Indicative pricing and availability. The buyer can compare and plan but must finalize before purchasing. Use the 'finalize' refine action to request firm pricing and any inventory hold.", - "committed": "Firm pricing with inventory reserved. The buyer can execute this proposal via create_media_buy before expires_at. Executing the committed proposal is buyer acceptance; finalization alone is not acceptance. After expires_at, the hold lapses and the buyer must re-finalize or re-discover." + "committed": "Firm, executable terms. The buyer can execute this proposal via create_media_buy before expires_at. The status does not by itself promise that inventory is reserved; a seller that provides a hold must state it in the proposal terms. Execution is buyer acceptance. After expires_at, the buyer must request or discover a fresh proposal." } } diff --git a/static/schemas/source/enums/task-type.json b/static/schemas/source/enums/task-type.json index 9fecf3d7c7..4ef662d979 100644 --- a/static/schemas/source/enums/task-type.json +++ b/static/schemas/source/enums/task-type.json @@ -15,7 +15,6 @@ "get_products", "request_proposals", "refine_proposals", - "finalize_proposals", "decline_proposals", "get_signals", "create_property_list", @@ -47,8 +46,7 @@ "activate_signal": "Signals domain: Activate an audience signal on a specific platform or account", "get_products": "Media-buy domain: Discover or refine advertising products and proposals", "request_proposals": "Media-buy domain: Request seller-authored proposals from a campaign brief", - "refine_proposals": "Media-buy domain: Create revised proposal drafts from existing proposals", - "finalize_proposals": "Media-buy domain: Atomically commit proposal drafts to firm terms", + "refine_proposals": "Media-buy domain: Create executable proposal revisions from existing proposals", "decline_proposals": "Media-buy domain: Terminally decline immutable proposals and prevent later execution", "get_signals": "Signals domain: Discover available audience signals based on natural language description", "create_property_list": "Property domain: Create a new property list with filters and brand reference", diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index bb3cd9ee73..9a69e71757 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -1341,7 +1341,7 @@ "request": { "$ref": "/schemas/media-buy/get-products-request.json", "deprecated": true, - "description": "AdCP 3.x compatibility request. New 3.2 callers use list_products, request_proposals, refine_proposals, finalize_proposals, or decline_proposals." + "description": "AdCP 3.x compatibility request. New 3.2 callers use list_products, request_proposals, refine_proposals, or decline_proposals." }, "response": { "$ref": "/schemas/media-buy/get-products-response.json", @@ -1379,16 +1379,6 @@ "description": "Response payload for refine_proposals" } }, - "finalize-proposals": { - "request": { - "$ref": "/schemas/media-buy/finalize-proposals-request.json", - "description": "Request parameters for atomically committing proposal pricing and inventory holds" - }, - "response": { - "$ref": "/schemas/media-buy/finalize-proposals-response.json", - "description": "Response payload for finalize_proposals" - } - }, "decline-proposals": { "request": { "$ref": "/schemas/media-buy/decline-proposals-request.json", diff --git a/static/schemas/source/media-buy/create-media-buy-request.json b/static/schemas/source/media-buy/create-media-buy-request.json index b43caa67c8..bd744fc535 100644 --- a/static/schemas/source/media-buy/create-media-buy-request.json +++ b/static/schemas/source/media-buy/create-media-buy-request.json @@ -77,7 +77,7 @@ "proposal_id": { "type": "string", "x-entity": "proposal", - "description": "ID of the exact committed proposal snapshot to execute. With total_budget, the publisher creates packages using the proposal's fixed percentages or seller-optimized constraints. Alternative to providing packages. For IDs issued by the AdCP 3.2 split proposal tasks, sellers MUST reject draft proposals with PROPOSAL_NOT_COMMITTED and declined or previously executed proposals with INVALID_STATE; exact retries with the original idempotency key still replay historical success. Changed commercial terms in that split lifecycle are issued under a new proposal_id, so no separate proposal version is required. Legacy get_products proposal behavior remains supported throughout AdCP 3.x." + "description": "ID of the exact committed proposal snapshot to execute. With total_budget, the publisher creates packages using the proposal's fixed percentages or seller-optimized constraints. Alternative to providing packages. AdCP 3.2 request_proposals and refine_proposals issue executable committed snapshots; sellers reject declined or previously executed snapshots with INVALID_STATE, while exact retries with the original idempotency key replay historical success. Changed commercial terms are issued under a new proposal_id, so no separate proposal version is required. Legacy get_products drafts remain supported throughout AdCP 3.x and fail with PROPOSAL_NOT_COMMITTED until finalized through that compatibility task." }, "opportunity": { "allOf": [ diff --git a/static/schemas/source/media-buy/finalize-proposals-request.json b/static/schemas/source/media-buy/finalize-proposals-request.json deleted file mode 100644 index 4ecefbefe1..0000000000 --- a/static/schemas/source/media-buy/finalize-proposals-request.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/media-buy/finalize-proposals-request.json", - "title": "Finalize Proposals Request", - "description": "Atomically commit one or more draft proposals to firm terms and inventory holds. Proposal ownership is resolved from the authenticated principal and opaque IDs.", - "type": "object", - "x-mutates-state": true, - "x-operation-family": "finalize_proposals", - "x-added-in": "3.2.0", - "properties": { - "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, - "adcp_major_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_major_version" }, - "context_id": { "type": "string", "minLength": 1 }, - "context": { - "allOf": [{ "$ref": "/schemas/core/context.json" }], - "x-adcp-schema-uri": "/schemas/core/context.json" - }, - "governance_context": { "type": "string", "minLength": 1, "maxLength": 4096 }, - "push_notification_config": { - "allOf": [{ "$ref": "/schemas/core/push-notification-config.json" }], - "x-adcp-schema-uri": "/schemas/core/push-notification-config.json" - }, - "idempotency_key": { - "type": "string", - "description": "Client-generated key required for retry-safe atomic finalization.", - "minLength": 16, - "maxLength": 255, - "pattern": "^[A-Za-z0-9_.:-]{16,255}$" - }, - "proposal_ids": { - "type": "array", - "description": "Unique proposal IDs to commit as one atomic operation.", - "items": { "type": "string", "minLength": 1, "x-entity": "proposal" }, - "minItems": 1, - "maxItems": 25, - "uniqueItems": true - } - }, - "required": ["idempotency_key", "proposal_ids"], - "additionalProperties": false -} diff --git a/static/schemas/source/media-buy/finalize-proposals-response.json b/static/schemas/source/media-buy/finalize-proposals-response.json deleted file mode 100644 index 0e43a7f274..0000000000 --- a/static/schemas/source/media-buy/finalize-proposals-response.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/media-buy/finalize-proposals-response.json", - "title": "Finalize Proposals Response", - "description": "Committed proposal snapshots returned only after every named proposal has been persisted atomically.", - "type": "object", - "properties": { - "proposals": { - "type": "array", - "minItems": 1, - "items": { - "allOf": [ - { "$ref": "/schemas/core/proposal.json" }, - { - "properties": { - "proposal_status": { "type": "string", "const": "committed" }, - "expires_at": { "type": "string", "format": "date-time" } - }, - "required": ["proposal_status", "expires_at"] - } - ] - } - } - }, - "required": ["proposals"], - "not": { - "anyOf": [ - { "required": ["products"] }, - { "required": ["refinement_applied"] }, - { "required": ["pagination"] } - ] - }, - "additionalProperties": true -} diff --git a/static/schemas/source/media-buy/get-products-request.json b/static/schemas/source/media-buy/get-products-request.json index 51c9592284..7a9c50315a 100644 --- a/static/schemas/source/media-buy/get-products-request.json +++ b/static/schemas/source/media-buy/get-products-request.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/get-products-request.json", "title": "Get Products Request", - "description": "AdCP 3.x compatibility request for discovering and changing advertising products and proposals. AdCP 3.2 introduces the narrower list_products, request_proposals, refine_proposals, finalize_proposals, and decline_proposals tasks; new callers SHOULD use those tasks. Existing get_products payloads remain valid throughout 3.x.", + "description": "AdCP 3.x compatibility request for discovering and changing advertising products and proposals. AdCP 3.2 introduces the narrower list_products, request_proposals, refine_proposals, and decline_proposals tasks; new callers SHOULD use those tasks. Existing get_products payloads remain valid throughout 3.x.", "type": "object", "allOf": [ { @@ -144,7 +144,7 @@ "finalize" ], "default": "include", - "description": "'include' (default): return this proposal with updated allocations and pricing. 'omit': exclude this proposal from the response. 'finalize': request firm pricing and inventory hold. New callers use the dedicated refine_proposals and finalize_proposals tasks; terminal feedback is available only through decline_proposals.\n\nFinalize is exclusive within the parent `refine[]` array: see the array-level description for the finalize-exclusivity rule (mixing finalize with non-finalize entries is rejected) and multi-finalize atomicity contract." + "description": "'include' (default): return this proposal with updated allocations and pricing. 'omit': exclude this proposal from the response. 'finalize': request firm pricing and inventory hold. New callers use refine_proposals, which returns an executable immutable revision directly; terminal feedback is available through decline_proposals.\n\nLegacy finalize is exclusive within the parent `refine[]` array: see the array-level description for the finalize-exclusivity rule (mixing finalize with non-finalize entries is rejected) and multi-finalize atomicity contract." }, "ask": { "type": "string", diff --git a/static/schemas/source/media-buy/list-creative-formats-request.json b/static/schemas/source/media-buy/list-creative-formats-request.json index 8b59201384..344146f622 100644 --- a/static/schemas/source/media-buy/list-creative-formats-request.json +++ b/static/schemas/source/media-buy/list-creative-formats-request.json @@ -3,6 +3,7 @@ "$id": "/schemas/media-buy/list-creative-formats-request.json", "title": "List Creative Formats Request", "deprecated": true, + "x-deprecated-in": "3.2.0", "description": "**DEPRECATED in 3.2.** Compatibility request for legacy named-format discovery. Sales agents expose purchasable canonical declarations on get_products `Product.format_options[]`; publisher acceptance comes from adagents.json `formats[]`; creative agents advertise build/validation/preview capabilities on get_adcp_capabilities `creative.supported_formats[]`.", "type": "object", "allOf": [ diff --git a/static/schemas/source/media-buy/proposal-refinement.json b/static/schemas/source/media-buy/proposal-refinement.json index a7b0c7bd69..2ce9287bba 100644 --- a/static/schemas/source/media-buy/proposal-refinement.json +++ b/static/schemas/source/media-buy/proposal-refinement.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/proposal-refinement.json", "title": "Proposal Refinement", - "description": "Requested revision of one draft proposal. A successful refinement produces a new draft proposal and leaves the source proposal unchanged.", + "description": "Requested revision of one executable proposal snapshot. A successful refinement produces a new immutable committed snapshot and leaves the source proposal unchanged.", "type": "object", "properties": { "proposal_id": { diff --git a/static/schemas/source/media-buy/refine-proposals-request.json b/static/schemas/source/media-buy/refine-proposals-request.json index 57b541a89f..1c7ed2c5a6 100644 --- a/static/schemas/source/media-buy/refine-proposals-request.json +++ b/static/schemas/source/media-buy/refine-proposals-request.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/refine-proposals-request.json", "title": "Refine Proposals Request", - "description": "Create revised drafts from one or more existing proposals. Each source proposal remains addressable so buyers can explore alternatives safely.", + "description": "Create executable revisions from one or more existing proposal snapshots. Each source remains addressable so buyers can explore alternatives safely.", "type": "object", "x-mutates-state": true, "x-operation-family": "refine_proposals", diff --git a/static/schemas/source/media-buy/refine-proposals-response.json b/static/schemas/source/media-buy/refine-proposals-response.json index 05f6918ba2..864abb41bf 100644 --- a/static/schemas/source/media-buy/refine-proposals-response.json +++ b/static/schemas/source/media-buy/refine-proposals-response.json @@ -28,7 +28,7 @@ { "$ref": "/schemas/core/proposal.json" }, { "properties": { - "proposal_status": { "type": "string", "const": "draft" }, + "proposal_status": { "type": "string", "const": "committed" }, "expires_at": { "type": "string", "format": "date-time" } }, "required": ["proposal_status", "expires_at"] diff --git a/static/schemas/source/media-buy/request-proposals-request.json b/static/schemas/source/media-buy/request-proposals-request.json index 94114a8e2c..343f564dda 100644 --- a/static/schemas/source/media-buy/request-proposals-request.json +++ b/static/schemas/source/media-buy/request-proposals-request.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/request-proposals-request.json", "title": "Request Proposals Request", - "description": "Ask a seller to create one or more actionable media-plan proposals from a brief. Each returned proposal_id identifies one immutable commercial snapshot and connects refinement, finalization, decline, and purchase; changed commercial terms require a new proposal_id.", + "description": "Ask a seller to create one or more executable media-plan proposals from a brief. Each returned proposal_id identifies one immutable committed snapshot and connects optional refinement, decline, and purchase; changed commercial terms require a new proposal_id.", "type": "object", "x-mutates-state": true, "x-operation-family": "request_proposals", diff --git a/static/schemas/source/media-buy/request-proposals-response.json b/static/schemas/source/media-buy/request-proposals-response.json index 9a2ad89f04..4e170aaf96 100644 --- a/static/schemas/source/media-buy/request-proposals-response.json +++ b/static/schemas/source/media-buy/request-proposals-response.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/request-proposals-response.json", "title": "Request Proposals Response", - "description": "One or more draft media-plan proposals and the exact products referenced by their allocations. Returning only products does not satisfy request_proposals.", + "description": "One or more executable media-plan proposals and the exact products referenced by their allocations. Returning only products does not satisfy request_proposals.", "type": "object", "discriminator": { "propertyName": "outcome" @@ -23,7 +23,7 @@ { "$ref": "/schemas/core/proposal.json" }, { "properties": { - "proposal_status": { "type": "string", "const": "draft" }, + "proposal_status": { "type": "string", "const": "committed" }, "expires_at": { "type": "string", "format": "date-time" } }, "required": ["proposal_status", "expires_at"] diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index 5958d5e892..ea57189753 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -11,28 +11,6 @@ { "$ref": "/schemas/core/protocol-envelope.json" }, - { - "if": { - "properties": { - "media_buy": { - "properties": { - "product_discovery_tools": { - "contains": { "const": "finalize_proposals" } - } - }, - "required": ["product_discovery_tools"] - } - }, - "required": ["media_buy"] - }, - "then": { - "properties": { - "media_buy": { - "required": ["max_atomic_finalize_batch_size"] - } - } - } - }, { "if": { "allOf": [ @@ -661,17 +639,11 @@ "description": "Product-discovery operation names this seller supports. Added in AdCP 3.2 as compact, task-specific contracts that form the 4.0 lifecycle foundation. Sellers may advertise any supported subset while retaining get_products throughout 3.x. Absence means the caller uses the legacy facade. Each stateful split task has its own idempotency identity; callers MUST retry with the same tool name.", "items": { "type": "string", - "enum": ["get_products", "list_products", "request_proposals", "refine_proposals", "finalize_proposals", "decline_proposals"] + "enum": ["get_products", "list_products", "request_proposals", "refine_proposals", "decline_proposals"] }, "minItems": 1, "uniqueItems": true }, - "max_atomic_finalize_batch_size": { - "type": "integer", - "minimum": 1, - "maximum": 25, - "description": "Maximum proposal_ids length the seller can commit atomically in finalize_proposals. Sellers supporting only single-proposal finalization declare 1. Required when finalize_proposals is advertised." - }, "reporting_delivery_methods": { "type": "array", "description": "How this seller delivers reporting data to buyers. Polling via get_media_buy_delivery is always available as a baseline regardless of this field. This array declares additional push-based delivery methods the seller supports. 'webhook': seller pushes to buyer-provided URL (configured per buy via reporting_webhook). 'offline': seller pushes batch files to a cloud storage bucket (seller-provisioned per account via reporting_bucket on the account object). When absent, only polling is available.", @@ -693,7 +665,7 @@ }, "supports_proposals": { "type": "boolean", - "description": "Conformance declaration that this seller supports actionable proposals through either the compact request/refine/finalize lifecycle or the legacy get_products facade. proposal_status is the per-proposal source of truth, and create_media_buy consumes the committed proposal_id before expires_at.", + "description": "Conformance declaration that this seller supports actionable proposals through either the compact request/refine lifecycle or the legacy get_products facade. create_media_buy consumes an executable proposal_id before expires_at.", "default": false }, "governance_aware": { diff --git a/tests/composed-schema-validation.test.cjs b/tests/composed-schema-validation.test.cjs index f1510a07cb..a6955faf29 100644 --- a/tests/composed-schema-validation.test.cjs +++ b/tests/composed-schema-validation.test.cjs @@ -3082,22 +3082,6 @@ async function runTests() { }, 'refine_proposals rejects finalization' ); - await testSchemaValidation( - '/schemas/media-buy/finalize-proposals-request.json', - { - idempotency_key: 'finalize-proposals-0001', - proposal_ids: ['proposal-1', 'proposal-2'] - }, - 'finalize_proposals accepts a unique atomic proposal set' - ); - await testSchemaRejection( - '/schemas/media-buy/finalize-proposals-request.json', - { - idempotency_key: 'finalize-proposals-0002', - proposal_ids: ['proposal-1', 'proposal-1'] - }, - 'finalize_proposals rejects duplicate proposal IDs' - ); await testSchemaValidation( '/schemas/media-buy/request-proposals-request.json', { @@ -3241,30 +3225,10 @@ async function runTests() { { ...splitCapabilityBase, media_buy: { - product_discovery_tools: ['request_proposals', 'finalize_proposals'], - max_atomic_finalize_batch_size: 25 + product_discovery_tools: ['request_proposals', 'refine_proposals'] } }, - 'finalize_proposals capability declares its atomic batch bound' - ); - await testSchemaRejection( - '/schemas/protocol/get-adcp-capabilities-response.json', - { - ...splitCapabilityBase, - media_buy: { product_discovery_tools: ['finalize_proposals'] } - }, - 'finalize_proposals capability rejects a missing atomic batch bound' - ); - await testSchemaRejection( - '/schemas/protocol/get-adcp-capabilities-response.json', - { - ...splitCapabilityBase, - media_buy: { - product_discovery_tools: ['finalize_proposals'], - max_atomic_finalize_batch_size: 26 - } - }, - 'finalize_proposals capability rejects a bound above the protocol ceiling' + 'compact proposal capability advertises supported split tools' ); await testSchemaValidation( '/schemas/media-buy/list-products-response.json', @@ -3284,7 +3248,7 @@ async function runTests() { proposal_id: 'proposal-1', name: 'Draft premium video plan', allocations: [{ product_id: 'premium-video', allocation_percentage: 100 }], - proposal_status: 'draft', + proposal_status: 'committed', expires_at: '2027-06-30T23:59:59Z' }], products: [{ ...productBase, product_id: 'premium-video' }] @@ -3331,7 +3295,7 @@ async function runTests() { proposal_id: 'proposal-2', name: 'Partially revised premium video plan', allocations: [{ product_id: 'premium-video', allocation_percentage: 100 }], - proposal_status: 'draft', + proposal_status: 'committed', expires_at: '2027-06-30T23:59:59Z' } }], @@ -3339,32 +3303,6 @@ async function runTests() { }, 'refine_proposals partial results require explanatory notes' ); - await testSchemaValidation( - '/schemas/media-buy/finalize-proposals-response.json', - { - proposals: [{ - proposal_id: 'proposal-1', - name: 'Committed premium video plan', - allocations: [{ product_id: 'premium-video', allocation_percentage: 100 }], - proposal_status: 'committed', - expires_at: '2027-06-30T23:59:59Z' - }] - }, - 'finalize_proposals returns committed proposals with hold expiry' - ); - await testSchemaRejection( - '/schemas/media-buy/finalize-proposals-response.json', - { - proposals: [{ - proposal_id: 'proposal-1', - name: 'Uncommitted premium video plan', - allocations: [{ product_id: 'premium-video', allocation_percentage: 100 }], - proposal_status: 'draft' - }] - }, - 'finalize_proposals rejects draft proposal results' - ); - log(''); log('SignalId compatibility during SignalRef migration:', 'info'); diff --git a/tests/docs-nav-validation.test.cjs b/tests/docs-nav-validation.test.cjs index f1e76a6a73..a74626477d 100644 --- a/tests/docs-nav-validation.test.cjs +++ b/tests/docs-nav-validation.test.cjs @@ -250,7 +250,6 @@ test('temporary snapshot redirects cover every available live page', () => { 'docs/media-buy/task-reference/list_products', 'docs/media-buy/task-reference/request_proposals', 'docs/media-buy/task-reference/refine_proposals', - 'docs/media-buy/task-reference/finalize_proposals', 'docs/media-buy/task-reference/decline_proposals', 'docs/creative/channels/radio', 'docs/brand-protocol/tasks/search_brands', diff --git a/tests/mcp-schema-projection.test.cjs b/tests/mcp-schema-projection.test.cjs index d441fda7c3..a23ac8eacf 100644 --- a/tests/mcp-schema-projection.test.cjs +++ b/tests/mcp-schema-projection.test.cjs @@ -24,6 +24,7 @@ const { compactDraft07Schema, measureSchema, projectDraft07Node, + stripPresentationAnnotations, } = require('../scripts/mcp-schema-projection.cjs'); const REPO_ROOT = path.resolve(__dirname, '..'); @@ -31,6 +32,7 @@ const SOURCE_DIR = path.join(REPO_ROOT, 'static', 'schemas', 'source'); const STORYBOARD_DIR = path.join(REPO_ROOT, 'static', 'compliance', 'source'); const LATEST_DIR = path.join(REPO_ROOT, 'dist', 'schemas', 'latest'); const PROJECTION_DIR = path.join(LATEST_DIR, 'mcp', MCP_PROTOCOL_VERSION); +const PRODUCTION_PROFILE_DIR = path.join(PROJECTION_DIR, 'profiles', 'production'); const PARITY_COMPILE_LIMIT = 1_000_000; function readJson(filename) { @@ -99,6 +101,32 @@ test('schema bounds include the complete JSON document', () => { }); }); +test('structural presentation mode removes only schema annotations', () => { + const source = { + title: 'Request title', + description: 'Request description', + type: 'object', + properties: { + payload: { + description: 'Field description', + const: { description: 'validated payload data' }, + default: { description: 'default payload data' }, + }, + }, + examples: [{ description: 'example payload data' }], + }; + + assert.deepEqual(stripPresentationAnnotations(source), { + type: 'object', + properties: { + payload: { + const: { description: 'validated payload data' }, + default: { description: 'default payload data' }, + }, + }, + }); +}); + test('draft-07 projection converts dialect-specific keywords without tightening', () => { const source = { $schema: 'http://json-schema.org/draft-07/schema#', @@ -315,6 +343,7 @@ test('generated MCP projection covers every tool within AdCP safety bounds', () const storyboardFixtures = collectStoryboardRequestFixtures(); assert.equal(projectionManifest.mcp_protocol_version, MCP_PROTOCOL_VERSION); assert.equal(projectionManifest.schema_dialect, JSON_SCHEMA_2020_12); + assert.equal(projectionManifest.annotation_mode, 'full'); assert.match(projectionManifest.delivery, /downloadable schema artifacts/); assert.deepEqual( Object.keys(projectionManifest.tools).sort(), @@ -435,3 +464,45 @@ test('generated MCP projection covers every tool within AdCP safety bounds', () `expected an invalid mutation for every parity schema, saw ${invalidParityCaseCount}` ); }); + +test('generated production profile exposes the active 3.2 surface without compliance annotations', () => { + assert.ok(fs.existsSync(PRODUCTION_PROFILE_DIR), 'production profile is missing'); + const canonicalManifest = readJson(path.join(LATEST_DIR, 'manifest.json')); + const profile = readJson(path.join(PRODUCTION_PROFILE_DIR, 'manifest.json')); + + assert.equal(profile.profile, 'production'); + assert.equal(profile.surface_version, '3.2.0'); + assert.equal(profile.annotation_mode, 'structural'); + assert.deepEqual(profile.filters, { + exclude_protocols: ['compliance'], + exclude_deprecated: true, + }); + + const expectedTools = Object.entries(canonicalManifest.tools) + .filter(([, tool]) => tool.protocol !== 'compliance') + .filter(([, tool]) => !tool.added_in || tool.added_in <= profile.surface_version) + .filter(([, tool]) => !tool.deprecated_in || tool.deprecated_in > profile.surface_version) + .map(([toolName]) => toolName) + .sort(); + assert.deepEqual(Object.keys(profile.tools).sort(), expectedTools); + assert.ok(!profile.tools.comply_test_controller); + assert.ok(!profile.tools.get_products); + assert.ok(!profile.tools.list_creative_formats); + for (const [toolName, tool] of Object.entries(profile.tools)) { + assert.equal(tool.protocol, canonicalManifest.tools[toolName].protocol); + assert.notEqual(tool.protocol, 'compliance'); + } + + let profileBytes = 0; + let canonicalBytes = 0; + const seen = new Set(); + for (const tool of Object.values(profile.tools)) { + for (const field of ['inputSchema', 'outputSchema']) { + if (seen.has(tool[field])) continue; + seen.add(tool[field]); + profileBytes += fs.statSync(path.join(PRODUCTION_PROFILE_DIR, tool[field])).size; + canonicalBytes += fs.statSync(path.join(PROJECTION_DIR, tool[field])).size; + } + } + assert.ok(profileBytes < canonicalBytes * 0.65, `${profileBytes} should be materially smaller than ${canonicalBytes}`); +}); From 0d4c13e4f4900c65a3578b6e5fbc499e9d3be2c3 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 11 Aug 2026 12:50:39 +0200 Subject: [PATCH 29/49] feat(protocol): make task results SDK-resolvable --- .changeset/secure-get-products-idempotency.md | 2 +- docs/building/by-layer/L3/task-lifecycle.mdx | 27 ++++ .../cross-cutting/version-adaptation.mdx | 15 +++ docs/protocol/get_adcp_capabilities.mdx | 2 +- docs/reference/release-notes.mdx | 2 + scripts/build-schemas.cjs | 125 +++++++++++++++++- .../source/core/async-response-data.json | 2 +- .../source/core/tasks-get-response.json | 5 +- static/schemas/source/enums/task-type.json | 3 + static/schemas/source/manifest.schema.json | 85 +++++++++++- .../media-buy/decline-proposals-request.json | 1 + .../media-buy/get-products-request.json | 1 + .../media-buy/list-products-request.json | 1 + .../media-buy/refine-proposals-request.json | 1 + .../media-buy/request-proposals-request.json | 1 + .../protocol/get-task-status-response.json | 5 +- tests/mcp-schema-projection.test.cjs | 77 +++++++++++ 17 files changed, 340 insertions(+), 15 deletions(-) diff --git a/.changeset/secure-get-products-idempotency.md b/.changeset/secure-get-products-idempotency.md index 36723ff898..4784896be1 100644 --- a/.changeset/secure-get-products-idempotency.md +++ b/.changeset/secure-get-products-idempotency.md @@ -2,4 +2,4 @@ "adcontextprotocol": minor --- -Add the compact AdCP 3.2 product lifecycle: `list_products`, `request_proposals`, `refine_proposals`, and `decline_proposals`. The task-specific contracts separate offer discovery from executable proposal creation, immutable refinement, terminal decline, and purchase while retaining `get_products` as a key-optional compatibility facade throughout 3.x. A shared opportunity reference connects planning-cycle context without duplicating proposal version identity. Publish an MCP production profile that filters out compliance-only and deprecated tools and removes presentation annotations without changing validation semantics. +Add the compact AdCP 3.2 product lifecycle: `list_products`, `request_proposals`, `refine_proposals`, and `decline_proposals`. The task-specific contracts separate offer discovery from executable proposal creation, immutable refinement, terminal decline, and purchase while retaining `get_products` as a key-optional compatibility facade throughout 3.x. A shared opportunity reference connects planning-cycle context without duplicating proposal version identity. Publish machine-readable SDK fallback grades and task-result schema resolution, plus an MCP production profile that filters out compliance-only and deprecated tools and removes presentation annotations without changing validation semantics. diff --git a/docs/building/by-layer/L3/task-lifecycle.mdx b/docs/building/by-layer/L3/task-lifecycle.mdx index a5001a9be0..62b04623ca 100644 --- a/docs/building/by-layer/L3/task-lifecycle.mdx +++ b/docs/building/by-layer/L3/task-lifecycle.mdx @@ -191,6 +191,33 @@ Don't poll for `working` — the server delivers the result on the open connecti Send `include_result: true` to receive the terminal task payload on the polled response once the task reaches `status: completed`. The `result` object on the response carries the same shape the original task would have returned synchronously — for example, polling a `create_media_buy` task returns `result: { media_buy_id, packages, status }`. For `failed` tasks, read the existing `error` field instead. Webhooks remain the supported delivery mechanism (see [Push Notifications](/docs/building/by-layer/L3/webhooks)); `include_result` is the typed polling alternative for buyers that prefer pull over push. +### Typed task results in SDKs + +AdCP 3.2 deliberately keeps the polling envelope's `result` property generic. Draft-07 cannot select a `$ref` dynamically from the sibling `task_type` value without enumerating every task, and embedding that enumeration made `get_task_status` grow whenever any async-capable tool grew. Exact result validation is still required; it is selected through the release manifest instead of duplicated into the polling schema. + +The release [`manifest.json`](https://adcontextprotocol.org/schemas/latest/manifest.json) therefore publishes `task_result_resolution`. For a terminal result, first check `terminal_schema_overrides[task_type]`; when no override exists, replace `{task_type}` in `/tools/{task_type}/response_schema`. A tool's canonical response schema already contains its task-local terminal branches, including the structured `get_products` rejection. SDK generators SHOULD use that rule to expose a generic relationship equivalent to: + +```ts +type ToolTaskType = Extract; +type TaskHandle = { taskId: string; taskType: T }; +type TaskResult = + T extends keyof TaskResultOverrides + ? SchemaType + : T extends ToolName + ? ToolResponse + : never; +type TaskStatus = { + task_id: string; + task_type: T; + status: AdcpTaskStatus; + result?: TaskResult; +}; +``` + +A tool call that returns `submitted` can retain its tool name in `TaskHandle`, so a later `getTaskStatus(handle)` returns the precise result type without importing a global union. SDKs SHOULD keep a string-only overload for recovered or persisted task IDs; that overload returns an unknown result until the response's `task_type` is checked, then resolves and validates the matching manifest schema or override. If a typed handle's expected tool differs from the returned `task_type`, the SDK MUST reject the response as a task-correlation failure. + +Webhook envelopes retain their separate async payload schema. For 3.x compatibility, task reconciliation also continues to accept the historically shared event value `media_buy_delivery`; its manifest override selects `media-buy-delivery-webhook-result.json`. AdCP 4.0 can separate event types from tool task types, remove the legacy `tasks/get` alias, and make this generic SDK relationship part of the canonical task model. + ```javascript // Polling is only for 'submitted' operations async function pollForResult(taskId, pollInterval = 30_000) { diff --git a/docs/building/cross-cutting/version-adaptation.mdx b/docs/building/cross-cutting/version-adaptation.mdx index 2028271476..a75d2a30b2 100644 --- a/docs/building/cross-cutting/version-adaptation.mdx +++ b/docs/building/cross-cutting/version-adaptation.mdx @@ -60,6 +60,21 @@ Each SDK ships per-tool adapter modules — pure shape translations (field renam When AdCP 3.1 ships and you bump the SDK, a new adapter folder appears for the now-legacy 3.0. Your handlers don't move. +### Same-major tool replacements + +AdCP 3.2's product lifecycle also replaces one broad 3.x facade with narrower tool names. This is not ordinary version-shape adaptation: some current operations need more than one legacy call, and one has no faithful legacy equivalent. The release manifest makes that distinction machine-readable through `legacy_fallback` on each replacement and `superseded_by` on `get_products`. + +| Current SDK method | Legacy peer tool | Fallback mode | Required behavior | +|---|---|---|---| +| `listProducts` | `get_products` wholesale | `orchestrated` | Translate the common case directly; when exact `product_ids` are requested, page and filter until every requested ID is found or the feed is exhausted | +| `requestProposals` | `get_products` brief, then finalize when needed | `orchestrated` | Return only executable committed snapshots; resume the same sequence safely on retry | +| `refineProposals` | `get_products` refine, then finalize when needed | `orchestrated` | Mint and return executable immutable revisions with source-result correspondence | +| `declineProposals` | none | `none` | Throw a typed capability error before sending a call; never silently discard terminal feedback | + +Client SDKs SHOULD expose the current methods as their primary API and choose the peer path from `media_buy.product_discovery_tools`. A directly advertised current tool always wins. A `direct` fallback may run transparently. An `orchestrated` fallback may run transparently only when the SDK ships a handwritten adapter that preserves the current tool's result completeness, terminal state, atomicity, and idempotency semantics; the manifest classification does not synthesize that state machine. Each underlying legacy mutation needs its own deterministic replay identity, and retrying the current SDK call must resume rather than restart the sequence. Otherwise the SDK MUST throw a typed `CapabilityUnsupportedError` (or language equivalent) carrying `requested_tool`, the peer's advertised tools, and `fallback_mode`. A `none` fallback MUST throw that error before sending a semantically different legacy call. + +These tools deliberately retain distinct `operation_family` values. Fallback metadata describes SDK adaptation, not cross-name authorization, idempotency equivalence, task recovery, or webhook identity. A client adapter MUST be authorized for the actual legacy `get_products` call; a grant for a split tool does not transfer across names. Server SDKs may use the inverse pattern—implement the current lifecycle once and derive the deprecated `get_products` facade for 3.x callers—but may advertise only behavior the adapter actually preserves. + ## Mechanism 2 — Migrate SDK majors via co-existence Use this when you bump your SDK from one major to the next and don't want to rewrite every handler the day you upgrade. Each SDK keeps the prior major's surface available alongside the new entry point. diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index 2a50aeeb8f..7407bba413 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -364,7 +364,7 @@ Media-buy protocol capabilities. Only present if `media_buy` is in `supported_pr #### product_discovery_tools -AdCP 3.2 sellers use `product_discovery_tools` to advertise any supported subset of `get_products`, `list_products`, `request_proposals`, `refine_proposals`, and `decline_proposals`. When absent, buyers use the legacy `get_products` facade and inspect `buying_modes`. The compact tools share authorization policy with legacy discovery, but each stateful tool has its own idempotency identity: retry with the same tool name and payload. +AdCP 3.2 sellers use `product_discovery_tools` to advertise any supported subset of `get_products`, `list_products`, `request_proposals`, `refine_proposals`, and `decline_proposals`. When absent, buyers use the legacy `get_products` facade and inspect `buying_modes`. The compact tools share the authorization framework with legacy discovery, but grants remain task-specific and each stateful tool has its own idempotency identity: retry with the same tool name and payload. SDKs use the release manifest's `legacy_fallback` metadata to distinguish a direct one-call translation from a stateful orchestration or an unsupported fallback; see [Same-major tool replacements](/docs/building/cross-cutting/version-adaptation#same-major-tool-replacements). :::note 3.0 breaking changes The following fields have been removed from the capabilities response: diff --git a/docs/reference/release-notes.mdx b/docs/reference/release-notes.mdx index e41bf2a01a..968f009a65 100644 --- a/docs/reference/release-notes.mdx +++ b/docs/reference/release-notes.mdx @@ -25,6 +25,8 @@ AdCP 3.2 adds `list_products`, `request_proposals`, `refine_proposals`, and `dec - Each stateful task requires an idempotency key and retains its own replay identity. Retries use the same tool name. - Brand input is an identity-only key, catalogs are compact selections, and offer filters are separated from exact targeting and future targeting support. - `media_buy.product_discovery_tools` advertises the split surface. Its absence means callers use `get_products` and `buying_modes`. +- The release manifest labels each legacy fallback as `direct`, `orchestrated`, or `none`, so SDKs can expose the split API without claiming lossy calls are aliases. +- `task_result_resolution` maps each tracked `task_type` to its tool `response_schema` or a retained 3.x event override, letting SDKs generate `TaskStatus` without embedding the global async-result union in `getTaskStatus`. New integrations SHOULD use the split tools. Existing integrations do not need an immediate migration: `get_products` keeps its optional-key 3.x contract, and sellers MUST honor replay protection whenever a key is supplied. diff --git a/scripts/build-schemas.cjs b/scripts/build-schemas.cjs index 4fac1860c9..956c4d5c06 100644 --- a/scripts/build-schemas.cjs +++ b/scripts/build-schemas.cjs @@ -31,6 +31,8 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); const semver = require('semver'); +const Ajv = require('ajv'); +const addFormats = require('ajv-formats'); const { MCP_PROTOCOL_VERSION, generateMcpSchemaProjection, @@ -1016,6 +1018,84 @@ function loadSpecialisms(repoRoot) { return out; } +function assertAcyclicToolEdges(tools, edgeFor, label) { + const visiting = new Set(); + const visited = new Set(); + const visit = name => { + if (visiting.has(name)) throw new Error(`Manifest generation: ${label} cycle includes ${name}`); + if (visited.has(name)) return; + visiting.add(name); + for (const target of edgeFor(tools.get(name)) || []) visit(target); + visiting.delete(name); + visited.add(name); + }; + for (const name of tools.keys()) visit(name); +} + +function validateManifestToolRelationships(toolList) { + const tools = new Map(toolList.map(tool => [tool.name, tool])); + for (const tool of toolList) { + const fallback = tool.legacy_fallback; + if (fallback?.tool) { + const target = tools.get(fallback.tool); + if (!target) throw new Error(`Manifest generation: ${tool.name} falls back to unknown tool ${fallback.tool}`); + if (fallback.tool === tool.name) throw new Error(`Manifest generation: ${tool.name} cannot fall back to itself`); + if (!target.deprecated_in) { + throw new Error(`Manifest generation: ${tool.name} fallback target ${fallback.tool} is not deprecated`); + } + } + for (const replacement of tool.superseded_by || []) { + const target = tools.get(replacement); + if (!target) throw new Error(`Manifest generation: ${tool.name} is superseded by unknown tool ${replacement}`); + if (replacement === tool.name) throw new Error(`Manifest generation: ${tool.name} cannot supersede itself`); + if (!tool.deprecated_in) { + throw new Error(`Manifest generation: active tool ${tool.name} cannot declare superseded_by`); + } + if (target.deprecated_in) { + throw new Error(`Manifest generation: ${tool.name} replacement ${replacement} is also deprecated`); + } + } + } + assertAcyclicToolEdges(tools, tool => tool.legacy_fallback?.tool ? [tool.legacy_fallback.tool] : [], 'legacy fallback'); + assertAcyclicToolEdges(tools, tool => tool.superseded_by || [], 'supersession'); +} + +function buildTaskResultResolution(sourceDir, toolsObj) { + const taskTypeSchema = JSON.parse( + fs.readFileSync(path.join(sourceDir, 'enums', 'task-type.json'), 'utf8') + ); + const terminalSchemaOverrides = taskTypeSchema['x-task-result-schema-overrides'] || {}; + for (const [taskType, schemaPath] of Object.entries(terminalSchemaOverrides)) { + if (!taskTypeSchema.enum.includes(taskType)) { + throw new Error(`Manifest generation: task result override names unknown task_type ${taskType}`); + } + if (toolsObj[taskType]) { + throw new Error(`Manifest generation: task result override ${taskType} also names a manifest tool`); + } + if (typeof schemaPath !== 'string' || !/^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*\.json$/.test(schemaPath)) { + throw new Error(`Manifest generation: task result override for ${taskType} has invalid schema path`); + } + const schemaFile = path.join(sourceDir, ...schemaPath.split('/')); + if (!fs.existsSync(schemaFile) || !fs.statSync(schemaFile).isFile()) { + throw new Error(`Manifest generation: task result override for ${taskType} does not exist`); + } + const schema = JSON.parse(fs.readFileSync(schemaFile, 'utf8')); + if (schema.$id !== `/schemas/${schemaPath}`) { + throw new Error(`Manifest generation: task result override for ${taskType} has mismatched schema identity`); + } + } + for (const taskType of taskTypeSchema.enum) { + if (!toolsObj[taskType] && !terminalSchemaOverrides[taskType]) { + throw new Error(`Manifest generation: task_type ${taskType} has no tool or terminal schema override`); + } + } + return { + discriminator_field: 'task_type', + terminal_schema_pointer_template: '/tools/{task_type}/response_schema', + terminal_schema_overrides: terminalSchemaOverrides + }; +} + function discoverTools(sourceDir) { const tools = []; const entries = fs.readdirSync(sourceDir, { withFileTypes: true }); @@ -1038,6 +1118,31 @@ function discoverTools(sourceDir) { const requestPath = path.join(protoDir, f.name); const requestSchema = JSON.parse(fs.readFileSync(requestPath, 'utf8')); const responseName = `${toolBase}-response.json`; + const responseSchema = `${protocol}/${responseName}`; + const legacyFallback = requestSchema['x-legacy-fallback']; + if (legacyFallback !== undefined) { + const keys = legacyFallback && typeof legacyFallback === 'object' && !Array.isArray(legacyFallback) + ? Object.keys(legacyFallback).sort() + : []; + const mode = legacyFallback?.mode; + const expectedKeys = mode === 'none' ? 'mode' : 'mode,tool'; + if ( + keys.join(',') !== expectedKeys + || !['direct', 'orchestrated', 'none'].includes(mode) + || (mode !== 'none' && !/^[a-z][a-z0-9_]*$/.test(legacyFallback.tool)) + ) { + throw new Error(`Manifest generation: ${protocol}/${f.name} has invalid x-legacy-fallback metadata`); + } + } + const supersededBy = requestSchema['x-superseded-by']; + if (supersededBy !== undefined && ( + !Array.isArray(supersededBy) + || supersededBy.length === 0 + || supersededBy.some(name => typeof name !== 'string' || !/^[a-z][a-z0-9_]*$/.test(name)) + || new Set(supersededBy).size !== supersededBy.length + )) { + throw new Error(`Manifest generation: ${protocol}/${f.name} has invalid x-superseded-by metadata`); + } const responsePath = path.join(protoDir, responseName); if (!fs.existsSync(responsePath)) { // A request with no matching response is a bug, not a tool — surface it. @@ -1065,14 +1170,17 @@ function discoverTools(sourceDir) { : 'none', ...(requestSchema['x-added-in'] ? { added_in: requestSchema['x-added-in'] } : {}), ...(requestSchema['x-deprecated-in'] ? { deprecated_in: requestSchema['x-deprecated-in'] } : {}), + ...(legacyFallback ? { legacy_fallback: legacyFallback } : {}), + ...(supersededBy ? { superseded_by: supersededBy } : {}), request_schema: `${protocol}/${f.name}`, - response_schema: `${protocol}/${responseName}`, + response_schema: responseSchema, async_response_schemas: asyncVariants }); } } // Sort tools alphabetically by name for stable output. tools.sort((a, b) => a.name.localeCompare(b.name)); + validateManifestToolRelationships(tools); return tools; } @@ -1143,7 +1251,9 @@ function buildManifest(sourceDir, urlVersion, semverVersion, repoRoot) { async_response_schemas: t.async_response_schemas, ...(t.specialisms ? { specialisms: t.specialisms } : {}), ...(t.added_in ? { added_in: t.added_in } : {}), - ...(t.deprecated_in ? { deprecated_in: t.deprecated_in } : {}) + ...(t.deprecated_in ? { deprecated_in: t.deprecated_in } : {}), + ...(t.legacy_fallback ? { legacy_fallback: t.legacy_fallback } : {}), + ...(t.superseded_by ? { superseded_by: t.superseded_by } : {}) }; } @@ -1152,6 +1262,7 @@ function buildManifest(sourceDir, urlVersion, semverVersion, repoRoot) { adcp_version: semverVersion, generated_at: new Date().toISOString(), tools: toolsObj, + task_result_resolution: buildTaskResultResolution(sourceDir, toolsObj), error_code_policy: { default_unknown_recovery: 'transient', note: "Sellers MAY return platform-specific codes that are not listed in error_codes. Agents MUST classify unknown codes as default_unknown_recovery and SHOULD retry with backoff before surfacing to the operator. Throwing on an unknown code is non-conformant client behavior." @@ -1163,6 +1274,14 @@ function buildManifest(sourceDir, urlVersion, semverVersion, repoRoot) { function writeManifest(sourceDir, targetDir, urlVersion, semverVersion, repoRoot) { const manifest = buildManifest(sourceDir, urlVersion, semverVersion, repoRoot); + const ajv = new Ajv({ allErrors: true, strict: false }); + addFormats(ajv); + const validateManifest = ajv.compile( + JSON.parse(fs.readFileSync(path.join(sourceDir, 'manifest.schema.json'), 'utf8')) + ); + if (!validateManifest(manifest)) { + throw new Error(`Generated manifest is invalid: ${ajv.errorsText(validateManifest.errors)}`); + } fs.writeFileSync( path.join(targetDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', @@ -2372,6 +2491,8 @@ module.exports = { buildRootSchemaDiscovery, isSelectableRelease, discoverTools, + buildTaskResultResolution, + validateManifestToolRelationships, }; if (require.main === module) { diff --git a/static/schemas/source/core/async-response-data.json b/static/schemas/source/core/async-response-data.json index 11c2d7c6ce..6026f2c092 100644 --- a/static/schemas/source/core/async-response-data.json +++ b/static/schemas/source/core/async-response-data.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/async-response-data.json", "title": "AdCP Async Response Data", - "description": "Union of all possible data payloads for async task webhook responses. For completed/failed statuses, use the main task response schema. For rejected get_products outcomes and working/input-required/submitted statuses, use the status-specific schemas. Because this shared union is not tagged with the originating task type, callers MUST also validate a terminal result against that task's response schema.", + "description": "Validation union of supported async webhook payloads. For completed/failed statuses, use the main task response schema. For rejected get_products outcomes and working/input-required/submitted statuses, use the status-specific schemas. Because this shared webhook union is not tagged with the originating task type, callers MUST also validate a terminal result against that task or event's specific schema. Polling responses use a generic result selected through manifest.task_result_resolution instead of embedding this union.", "anyOf": [ { "title": "GetProductsResponse", diff --git a/static/schemas/source/core/tasks-get-response.json b/static/schemas/source/core/tasks-get-response.json index aacb00b6ee..d2313ee572 100644 --- a/static/schemas/source/core/tasks-get-response.json +++ b/static/schemas/source/core/tasks-get-response.json @@ -170,8 +170,9 @@ } }, "result": { - "$ref": "/schemas/core/async-response-data.json", - "description": "Task-specific completion payload. Present when status is 'completed' and include_result was true in the request; absent otherwise. For failed tasks, use the error field instead. Uses the same anyOf union as the push-notification webhook result field." + "type": "object", + "additionalProperties": true, + "description": "Task-specific terminal payload. Present when include_result was true and the task has a result; absent otherwise. For failed tasks, use the error field instead. Consumers and sellers MUST resolve and validate the exact schema through manifest.task_result_resolution: use terminal_schema_overrides[task_type] when present, otherwise tools[task_type].response_schema. The polling envelope keeps this field generic so tasks/get does not embed every task response schema." }, "context": { "$ref": "/schemas/core/context.json" diff --git a/static/schemas/source/enums/task-type.json b/static/schemas/source/enums/task-type.json index 4ef662d979..80fa926223 100644 --- a/static/schemas/source/enums/task-type.json +++ b/static/schemas/source/enums/task-type.json @@ -68,6 +68,9 @@ "update_rights": "Brand domain: Update an existing rights grant, including its term, impression cap, pricing option, or pause state", "sync_agent_notification_configs": "Protocol domain: Register agent-level webhook subscribers such as capabilities.changed cache-invalidation notifications" }, + "x-task-result-schema-overrides": { + "media_buy_delivery": "media-buy/media-buy-delivery-webhook-result.json" + }, "notes": [ "Task types map to specific AdCP task operations", "Each task type belongs to the 'media-buy', 'signals', 'property', 'account', 'creative', or 'brand' domain", diff --git a/static/schemas/source/manifest.schema.json b/static/schemas/source/manifest.schema.json index 1f61255340..599c7b1676 100644 --- a/static/schemas/source/manifest.schema.json +++ b/static/schemas/source/manifest.schema.json @@ -4,7 +4,7 @@ "title": "AdCP Manifest", "description": "Machine-readable registry of every AdCP tool, error code, and specialism for a given AdCP version. SDKs consume this artifact at codegen time to derive their tool/error tables instead of hand-transcribing the spec. Replaces three categories of drift documented in adcp#3725: hand-rolled tool-by-protocol arrays, hand-classified error recovery, and hand-listed specialism→tool mappings.", "type": "object", - "required": ["adcp_version", "generated_at", "tools", "error_codes", "error_code_policy", "specialisms"], + "required": ["adcp_version", "generated_at", "tools", "task_result_resolution", "error_codes", "error_code_policy", "specialisms"], "additionalProperties": false, "properties": { "$schema": { @@ -25,6 +25,7 @@ "type": "object", "description": "Every tool the AdCP spec defines, keyed by tool name (the snake_case name used in MCP/A2A invocations).", "minProperties": 1, + "propertyNames": { "$ref": "#/definitions/tool_name" }, "additionalProperties": { "type": "object", "required": ["protocol", "mutating", "request_schema", "response_schema", "async_response_schemas"], @@ -64,17 +65,55 @@ "description": "Whether callers must, may, or cannot supply idempotency_key for this tool. Optional keys, when supplied, receive the same replay guarantees as required keys." }, "request_schema": { - "type": "string", - "description": "Path to the request schema, relative to the manifest's directory (e.g., 'media-buy/create-media-buy-request.json')." + "$ref": "#/definitions/schema_path" }, "response_schema": { - "type": "string", - "description": "Path to the synchronous response schema, relative to the manifest's directory." + "$ref": "#/definitions/schema_path" }, "async_response_schemas": { "type": "array", "description": "Paths to the tool's async response variants (typically -submitted, -working, -input-required). Empty array if the tool has no async surface. Buyer agents MUST handle every entry — a tool with async_response_schemas non-empty can return any of these from a non-final task state.", - "items": { "type": "string" } + "items": { "$ref": "#/definitions/schema_path" } + }, + "legacy_fallback": { + "type": "object", + "description": "How an SDK exposing this tool's current API may satisfy it against a peer that only exposes a legacy tool. This is caller-side adaptation metadata, not authorization or idempotency equivalence.", + "additionalProperties": false, + "properties": { + "tool": { + "$ref": "#/definitions/tool_name", + "description": "Legacy peer tool targeted by the adapter." + }, + "mode": { + "type": "string", + "enum": ["direct", "orchestrated", "none"], + "description": "direct: one lossless translated call; orchestrated: the SDK must sequence legacy calls and preserve the current tool's semantics; none: no faithful legacy fallback exists." + } + }, + "required": ["mode"], + "discriminator": { "propertyName": "mode" }, + "oneOf": [ + { + "properties": { "mode": { "const": "direct" } }, + "required": ["mode", "tool"] + }, + { + "properties": { "mode": { "const": "orchestrated" } }, + "required": ["mode", "tool"] + }, + { + "properties": { "mode": { "const": "none" } }, + "required": ["mode"], + "not": { "required": ["tool"] } + } + ] + }, + "superseded_by": { + "type": "array", + "description": "Current tool names that replace this deprecated facade. This does not imply that each replacement is a one-call alias.", + "items": { "$ref": "#/definitions/tool_name" }, + "minItems": 1, + "uniqueItems": true }, "specialisms": { "type": "array", @@ -92,6 +131,28 @@ } } }, + "task_result_resolution": { + "type": "object", + "description": "Machine-readable rule for resolving the schema of a tracked task result without generating the full async-response-data union as an SDK type. Consumers check terminal_schema_overrides first, then apply terminal_schema_pointer_template.", + "required": ["discriminator_field", "terminal_schema_pointer_template", "terminal_schema_overrides"], + "additionalProperties": false, + "properties": { + "discriminator_field": { + "type": "string", + "const": "task_type" + }, + "terminal_schema_pointer_template": { + "type": "string", + "const": "/tools/{task_type}/response_schema" + }, + "terminal_schema_overrides": { + "type": "object", + "description": "Result schemas for retained 3.x task_type values that do not name manifest tools, such as media_buy_delivery.", + "propertyNames": { "$ref": "#/definitions/tool_name" }, + "additionalProperties": { "$ref": "#/definitions/schema_path" } + } + } + }, "error_code_policy": { "type": "object", "description": "How SDKs should handle codes outside the standard set. The error vocabulary is open: sellers MAY return platform-specific codes that aren't in `error_codes`. Agents MUST fall back to `default_unknown_recovery` for unknown codes — they SHOULD NOT throw or treat unknown codes as malformed responses.", @@ -167,5 +228,17 @@ } } } + }, + "definitions": { + "tool_name": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$", + "description": "Canonical snake_case tool name safe for use as a manifest JSON Pointer segment." + }, + "schema_path": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*\\.json$", + "description": "Normalized relative path to a JSON schema within the published release root." + } } } diff --git a/static/schemas/source/media-buy/decline-proposals-request.json b/static/schemas/source/media-buy/decline-proposals-request.json index 496e365d68..adb0910715 100644 --- a/static/schemas/source/media-buy/decline-proposals-request.json +++ b/static/schemas/source/media-buy/decline-proposals-request.json @@ -7,6 +7,7 @@ "x-mutates-state": true, "x-operation-family": "decline_proposals", "x-added-in": "3.2.0", + "x-legacy-fallback": { "mode": "none" }, "properties": { "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "adcp_major_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_major_version" }, diff --git a/static/schemas/source/media-buy/get-products-request.json b/static/schemas/source/media-buy/get-products-request.json index 7a9c50315a..fdbea00b4b 100644 --- a/static/schemas/source/media-buy/get-products-request.json +++ b/static/schemas/source/media-buy/get-products-request.json @@ -40,6 +40,7 @@ "x-idempotency-key-required": false, "x-operation-family": "get_products", "x-deprecated-in": "3.2.0", + "x-superseded-by": ["list_products", "request_proposals", "refine_proposals", "decline_proposals"], "properties": { "idempotency_key": { "type": "string", diff --git a/static/schemas/source/media-buy/list-products-request.json b/static/schemas/source/media-buy/list-products-request.json index 758c99b6ab..d194504217 100644 --- a/static/schemas/source/media-buy/list-products-request.json +++ b/static/schemas/source/media-buy/list-products-request.json @@ -6,6 +6,7 @@ "type": "object", "x-operation-family": "list_products", "x-added-in": "3.2.0", + "x-legacy-fallback": { "tool": "get_products", "mode": "orchestrated" }, "properties": { "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "adcp_major_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_major_version" }, diff --git a/static/schemas/source/media-buy/refine-proposals-request.json b/static/schemas/source/media-buy/refine-proposals-request.json index 1c7ed2c5a6..ee54937f0c 100644 --- a/static/schemas/source/media-buy/refine-proposals-request.json +++ b/static/schemas/source/media-buy/refine-proposals-request.json @@ -7,6 +7,7 @@ "x-mutates-state": true, "x-operation-family": "refine_proposals", "x-added-in": "3.2.0", + "x-legacy-fallback": { "tool": "get_products", "mode": "orchestrated" }, "properties": { "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "adcp_major_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_major_version" }, diff --git a/static/schemas/source/media-buy/request-proposals-request.json b/static/schemas/source/media-buy/request-proposals-request.json index 343f564dda..e2e264119e 100644 --- a/static/schemas/source/media-buy/request-proposals-request.json +++ b/static/schemas/source/media-buy/request-proposals-request.json @@ -7,6 +7,7 @@ "x-mutates-state": true, "x-operation-family": "request_proposals", "x-added-in": "3.2.0", + "x-legacy-fallback": { "tool": "get_products", "mode": "orchestrated" }, "properties": { "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "adcp_major_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_major_version" }, diff --git a/static/schemas/source/protocol/get-task-status-response.json b/static/schemas/source/protocol/get-task-status-response.json index 91d15b6a62..ceced6ed15 100644 --- a/static/schemas/source/protocol/get-task-status-response.json +++ b/static/schemas/source/protocol/get-task-status-response.json @@ -170,8 +170,9 @@ } }, "result": { - "$ref": "/schemas/core/async-response-data.json", - "description": "Task-specific completion payload. Present when status is 'completed' and include_result was true in the request; absent otherwise. For failed tasks, use the error field instead. Uses the same anyOf union as the push-notification webhook result field." + "type": "object", + "additionalProperties": true, + "description": "Task-specific terminal payload. Present when include_result was true and the task has a result; absent otherwise. For failed tasks, use the error field instead. Consumers and sellers MUST resolve and validate the exact schema through manifest.task_result_resolution: use terminal_schema_overrides[task_type] when present, otherwise tools[task_type].response_schema. The polling envelope keeps this field generic so get_task_status does not embed every task response schema." }, "context": { "$ref": "/schemas/core/context.json" diff --git a/tests/mcp-schema-projection.test.cjs b/tests/mcp-schema-projection.test.cjs index a23ac8eacf..107d1cf302 100644 --- a/tests/mcp-schema-projection.test.cjs +++ b/tests/mcp-schema-projection.test.cjs @@ -13,6 +13,10 @@ const yaml = require('js-yaml'); const { normalizeSubstitutions, } = require('../scripts/lint-storyboard-sample-request-schema.cjs'); +const { + buildTaskResultResolution, + validateManifestToolRelationships, +} = require('../scripts/build-schemas.cjs'); const { JSON_SCHEMA_2020_12, MAX_SCHEMA_BYTES, @@ -39,6 +43,37 @@ function readJson(filename) { return JSON.parse(fs.readFileSync(filename, 'utf8')); } +test('manifest tool relationships reject unknown, self-referential, and cyclic adapters', () => { + const legacy = { name: 'legacy_tool', deprecated_in: '3.2.0' }; + const current = { + name: 'current_tool', + legacy_fallback: { tool: 'legacy_tool', mode: 'orchestrated' }, + }; + assert.doesNotThrow(() => validateManifestToolRelationships([legacy, current])); + assert.throws( + () => validateManifestToolRelationships([legacy, { ...current, legacy_fallback: { tool: 'missing_tool', mode: 'direct' } }]), + /unknown tool/ + ); + assert.throws( + () => validateManifestToolRelationships([{ ...legacy, legacy_fallback: { tool: 'legacy_tool', mode: 'direct' } }]), + /itself/ + ); + assert.throws( + () => validateManifestToolRelationships([ + { name: 'legacy_a', deprecated_in: '3.2.0', legacy_fallback: { tool: 'legacy_b', mode: 'direct' } }, + { name: 'legacy_b', deprecated_in: '3.2.0', legacy_fallback: { tool: 'legacy_a', mode: 'direct' } }, + ]), + /cycle/ + ); +}); + +test('task result overrides cannot replace a manifest tool response schema', () => { + assert.throws( + () => buildTaskResultResolution(SOURCE_DIR, { media_buy_delivery: { response_schema: 'wrong.json' } }), + /also names a manifest tool/ + ); +}); + function createValidator(AjvClass) { const ajv = new AjvClass({ addUsedSchema: false, @@ -341,14 +376,56 @@ test('generated MCP projection covers every tool within AdCP safety bounds', () const canonicalManifest = readJson(path.join(LATEST_DIR, 'manifest.json')); const projectionManifest = readJson(path.join(PROJECTION_DIR, 'manifest.json')); const storyboardFixtures = collectStoryboardRequestFixtures(); + const validateCanonicalManifest = createValidator(AjvDraft07).compile( + readJson(path.join(SOURCE_DIR, 'manifest.schema.json')) + ); + assert.equal( + validateCanonicalManifest(canonicalManifest), + true, + JSON.stringify(validateCanonicalManifest.errors) + ); assert.equal(projectionManifest.mcp_protocol_version, MCP_PROTOCOL_VERSION); assert.equal(projectionManifest.schema_dialect, JSON_SCHEMA_2020_12); assert.equal(projectionManifest.annotation_mode, 'full'); assert.match(projectionManifest.delivery, /downloadable schema artifacts/); + assert.deepEqual(canonicalManifest.task_result_resolution, { + discriminator_field: 'task_type', + terminal_schema_pointer_template: '/tools/{task_type}/response_schema', + terminal_schema_overrides: { + media_buy_delivery: 'media-buy/media-buy-delivery-webhook-result.json', + }, + }); + const taskTypes = readJson(path.join(SOURCE_DIR, 'enums', 'task-type.json')).enum; + for (const taskType of taskTypes) { + const selectedSchema = canonicalManifest.task_result_resolution.terminal_schema_overrides[taskType] + || canonicalManifest.tools[taskType]?.response_schema; + assert.equal(typeof selectedSchema, 'string', `${taskType} must resolve to a terminal schema`); + assert.ok(fs.existsSync(path.join(LATEST_DIR, selectedSchema)), `${taskType} result schema must exist`); + } + assert.deepEqual(canonicalManifest.tools.get_products.superseded_by, [ + 'list_products', + 'request_proposals', + 'refine_proposals', + 'decline_proposals', + ]); + assert.deepEqual(canonicalManifest.tools.list_products.legacy_fallback, { + tool: 'get_products', + mode: 'orchestrated', + }); + assert.equal(canonicalManifest.tools.request_proposals.legacy_fallback.mode, 'orchestrated'); + assert.equal(canonicalManifest.tools.refine_proposals.legacy_fallback.mode, 'orchestrated'); + assert.deepEqual(canonicalManifest.tools.decline_proposals.legacy_fallback, { mode: 'none' }); assert.deepEqual( Object.keys(projectionManifest.tools).sort(), Object.keys(canonicalManifest.tools).sort() ); + const projectedTaskStatus = readJson( + path.join(PROJECTION_DIR, projectionManifest.tools.get_task_status.outputSchema) + ); + assert.ok( + measureSchema(projectedTaskStatus).bytes < 100_000, + 'generic get_task_status output should not embed the global task-result union' + ); const seen = new Set(); const paritySchemas = new Map(); From d45fce13e96346a37fc11aace4b1bb6d3b98cee8 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 11 Aug 2026 13:02:26 +0200 Subject: [PATCH 30/49] docs(protocol): pin manifest link to v3 --- docs/building/by-layer/L3/task-lifecycle.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/building/by-layer/L3/task-lifecycle.mdx b/docs/building/by-layer/L3/task-lifecycle.mdx index 62b04623ca..59f34e488e 100644 --- a/docs/building/by-layer/L3/task-lifecycle.mdx +++ b/docs/building/by-layer/L3/task-lifecycle.mdx @@ -195,7 +195,7 @@ Send `include_result: true` to receive the terminal task payload on the polled r AdCP 3.2 deliberately keeps the polling envelope's `result` property generic. Draft-07 cannot select a `$ref` dynamically from the sibling `task_type` value without enumerating every task, and embedding that enumeration made `get_task_status` grow whenever any async-capable tool grew. Exact result validation is still required; it is selected through the release manifest instead of duplicated into the polling schema. -The release [`manifest.json`](https://adcontextprotocol.org/schemas/latest/manifest.json) therefore publishes `task_result_resolution`. For a terminal result, first check `terminal_schema_overrides[task_type]`; when no override exists, replace `{task_type}` in `/tools/{task_type}/response_schema`. A tool's canonical response schema already contains its task-local terminal branches, including the structured `get_products` rejection. SDK generators SHOULD use that rule to expose a generic relationship equivalent to: +The release [`manifest.json`](https://adcontextprotocol.org/schemas/v3/manifest.json) therefore publishes `task_result_resolution`. For a terminal result, first check `terminal_schema_overrides[task_type]`; when no override exists, replace `{task_type}` in `/tools/{task_type}/response_schema`. A tool's canonical response schema already contains its task-local terminal branches, including the structured `get_products` rejection. SDK generators SHOULD use that rule to expose a generic relationship equivalent to: ```ts type ToolTaskType = Extract; From 452b2ff4269fce6f1ab9fe33bbdf27361b19429c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 11 Aug 2026 13:06:55 +0200 Subject: [PATCH 31/49] docs(protocol): describe generated manifest artifact --- docs/building/by-layer/L3/task-lifecycle.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/building/by-layer/L3/task-lifecycle.mdx b/docs/building/by-layer/L3/task-lifecycle.mdx index 59f34e488e..76ad33f75a 100644 --- a/docs/building/by-layer/L3/task-lifecycle.mdx +++ b/docs/building/by-layer/L3/task-lifecycle.mdx @@ -195,7 +195,7 @@ Send `include_result: true` to receive the terminal task payload on the polled r AdCP 3.2 deliberately keeps the polling envelope's `result` property generic. Draft-07 cannot select a `$ref` dynamically from the sibling `task_type` value without enumerating every task, and embedding that enumeration made `get_task_status` grow whenever any async-capable tool grew. Exact result validation is still required; it is selected through the release manifest instead of duplicated into the polling schema. -The release [`manifest.json`](https://adcontextprotocol.org/schemas/v3/manifest.json) therefore publishes `task_result_resolution`. For a terminal result, first check `terminal_schema_overrides[task_type]`; when no override exists, replace `{task_type}` in `/tools/{task_type}/response_schema`. A tool's canonical response schema already contains its task-local terminal branches, including the structured `get_products` rejection. SDK generators SHOULD use that rule to expose a generic relationship equivalent to: +The release `manifest.json` artifact therefore publishes `task_result_resolution`. For a terminal result, first check `terminal_schema_overrides[task_type]`; when no override exists, replace `{task_type}` in `/tools/{task_type}/response_schema`. A tool's canonical response schema already contains its task-local terminal branches, including the structured `get_products` rejection. SDK generators SHOULD use that rule to expose a generic relationship equivalent to: ```ts type ToolTaskType = Extract; From 45f3b0eb42d35ff4a06d1c9dc81fcfaf177b7719 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 11 Aug 2026 13:16:21 +0200 Subject: [PATCH 32/49] fix(build): avoid task schema validation race --- scripts/build-schemas.cjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/build-schemas.cjs b/scripts/build-schemas.cjs index 956c4d5c06..936966d360 100644 --- a/scripts/build-schemas.cjs +++ b/scripts/build-schemas.cjs @@ -1076,10 +1076,13 @@ function buildTaskResultResolution(sourceDir, toolsObj) { throw new Error(`Manifest generation: task result override for ${taskType} has invalid schema path`); } const schemaFile = path.join(sourceDir, ...schemaPath.split('/')); - if (!fs.existsSync(schemaFile) || !fs.statSync(schemaFile).isFile()) { - throw new Error(`Manifest generation: task result override for ${taskType} does not exist`); + let schemaContents; + try { + schemaContents = fs.readFileSync(schemaFile, 'utf8'); + } catch (error) { + throw new Error(`Manifest generation: task result override for ${taskType} cannot be read`, { cause: error }); } - const schema = JSON.parse(fs.readFileSync(schemaFile, 'utf8')); + const schema = JSON.parse(schemaContents); if (schema.$id !== `/schemas/${schemaPath}`) { throw new Error(`Manifest generation: task result override for ${taskType} has mismatched schema identity`); } From e15d65e70e5fb6035f3c8f4ce324d67b9b658c07 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 12 Aug 2026 04:52:49 +0200 Subject: [PATCH 33/49] keep proposal inputs provenance-free --- .changeset/secure-get-products-idempotency.md | 2 +- ...roduct-audience-evidence-requirements.json | 126 +++++++++++++ .../source/core/product-offer-filters.json | 166 +++++++++++++++--- static/schemas/source/index.json | 4 + tests/mcp-schema-projection.test.cjs | 15 ++ 5 files changed, 285 insertions(+), 28 deletions(-) create mode 100644 static/schemas/source/core/product-audience-evidence-requirements.json diff --git a/.changeset/secure-get-products-idempotency.md b/.changeset/secure-get-products-idempotency.md index 4784896be1..6e4e791b8e 100644 --- a/.changeset/secure-get-products-idempotency.md +++ b/.changeset/secure-get-products-idempotency.md @@ -2,4 +2,4 @@ "adcontextprotocol": minor --- -Add the compact AdCP 3.2 product lifecycle: `list_products`, `request_proposals`, `refine_proposals`, and `decline_proposals`. The task-specific contracts separate offer discovery from executable proposal creation, immutable refinement, terminal decline, and purchase while retaining `get_products` as a key-optional compatibility facade throughout 3.x. A shared opportunity reference connects planning-cycle context without duplicating proposal version identity. Publish machine-readable SDK fallback grades and task-result schema resolution, plus an MCP production profile that filters out compliance-only and deprecated tools and removes presentation annotations without changing validation semantics. +Add the compact AdCP 3.2 product lifecycle: `list_products`, `request_proposals`, `refine_proposals`, and `decline_proposals`. The task-specific contracts separate offer discovery from executable proposal creation, immutable refinement, terminal decline, and purchase while retaining `get_products` as a key-optional compatibility facade throughout 3.x. A shared opportunity reference connects planning-cycle context without duplicating proposal version identity. Product-discovery inputs use stable brand keys and an offer-only filter graph, preventing buyer calls from transitively embedding brand assets or content provenance. Publish machine-readable SDK fallback grades and task-result schema resolution, plus an MCP production profile that filters out compliance-only and deprecated tools and removes presentation annotations without changing validation semantics. diff --git a/static/schemas/source/core/product-audience-evidence-requirements.json b/static/schemas/source/core/product-audience-evidence-requirements.json new file mode 100644 index 0000000000..2eba58d1ad --- /dev/null +++ b/static/schemas/source/core/product-audience-evidence-requirements.json @@ -0,0 +1,126 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/product-audience-evidence-requirements.json", + "title": "Product Audience Evidence Requirements", + "description": "Buyer-authored evidence policy for compact product discovery. Organization selectors are stable keys and never carry brand assets or content provenance.", + "type": "object", + "properties": { + "requirement_mode": { "type": "string", "enum": ["required", "preferred"] }, + "evidence_presence": { "type": "string", "enum": ["required", "when_available"] }, + "accepted_methodologies": { + "type": "array", + "items": { "$ref": "/schemas/enums/audience-evidence-methodology.json" }, + "minItems": 1, + "uniqueItems": true + }, + "excluded_methodologies": { + "type": "array", + "items": { "$ref": "/schemas/enums/audience-evidence-methodology.json" }, + "minItems": 1, + "uniqueItems": true + }, + "accepted_evidence_types": { + "type": "array", + "items": { "type": "string", "enum": ["measured", "forecast", "seller_declared"] }, + "minItems": 1, + "uniqueItems": true + }, + "accepted_providers": { + "type": "array", + "items": { "$ref": "/schemas/core/brand-key.json" }, + "minItems": 1 + }, + "excluded_providers": { + "type": "array", + "items": { "$ref": "/schemas/core/brand-key.json" }, + "minItems": 1 + }, + "accepted_subject_types": { + "type": "array", + "items": { "$ref": "/schemas/enums/audience-subject-type.json" }, + "minItems": 1, + "uniqueItems": true + }, + "accepted_resolution_methods": { + "type": "array", + "items": { "$ref": "/schemas/enums/audience-resolution-method.json" }, + "minItems": 1, + "uniqueItems": true + }, + "minimum_confidence": { "type": "number", "minimum": 0, "maximum": 1 }, + "maximum_age": { + "allOf": [ + { "$ref": "/schemas/core/duration.json" }, + { "properties": { "unit": { "enum": ["seconds", "minutes", "hours", "days"] } } } + ] + }, + "methodology_documentation_required": { "type": "boolean", "default": false }, + "independent_attestation_required": { "type": "boolean", "default": false }, + "accepted_attestation_issuers": { + "type": "array", + "items": { + "discriminator": { "propertyName": "type" }, + "oneOf": [ + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "brand" }, + "brand": { "$ref": "/schemas/core/brand-key.json" }, + "ext": { "$ref": "/schemas/core/ext.json" } + }, + "required": ["type", "brand"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "agent" }, + "agent_url": { + "type": "string", + "format": "uri", + "pattern": "^https://[^/?#@]+(?:/[^?#]*)?(?:\\?[^#]*)?$" + }, + "ext": { "$ref": "/schemas/core/ext.json" } + }, + "required": ["type", "agent_url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "origin" }, + "origin": { + "type": "string", + "format": "uri", + "pattern": "^https://[^/?#@]+$" + }, + "ext": { "$ref": "/schemas/core/ext.json" } + }, + "required": ["type", "origin"], + "additionalProperties": false + } + ] + }, + "minItems": 1, + "uniqueItems": true + }, + "accepted_attestation_claim_types": { + "type": "array", + "items": { "type": "string", "format": "uri" }, + "minItems": 1, + "uniqueItems": true + }, + "ext": { "$ref": "/schemas/core/ext.json" } + }, + "required": ["requirement_mode", "evidence_presence"], + "allOf": [ + { + "if": { + "properties": { "independent_attestation_required": { "const": true } }, + "required": ["independent_attestation_required"] + }, + "then": { "required": ["accepted_attestation_issuers"] } + } + ], + "additionalProperties": false +} diff --git a/static/schemas/source/core/product-offer-filters.json b/static/schemas/source/core/product-offer-filters.json index 90a4832999..8382ba6456 100644 --- a/static/schemas/source/core/product-offer-filters.json +++ b/static/schemas/source/core/product-offer-filters.json @@ -2,45 +2,157 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/product-offer-filters.json", "title": "Product Offer Filters", - "description": "Offer, commercial-fit, availability, and reporting filters for product discovery. Delivery targeting is intentionally outside this offer-only filter object.", + "description": "Offer, commercial-fit, availability, and reporting filters for product discovery. This compact schema is independent of the legacy delivery-targeting filter graph. Brand and issuer selectors are stable keys; callers do not send brand assets or content provenance.", "type": "object", "properties": { - "delivery_type": { "$ref": "/schemas/core/product-filters.json#/properties/delivery_type" }, - "exclusivity": { "$ref": "/schemas/core/product-filters.json#/properties/exclusivity" }, - "is_fixed_price": { "$ref": "/schemas/core/product-filters.json#/properties/is_fixed_price" }, - "pricing_structures": { "$ref": "/schemas/core/product-filters.json#/properties/pricing_structures" }, - "pricing_currencies": { "$ref": "/schemas/core/product-filters.json#/properties/pricing_currencies" }, - "format_kinds": { "$ref": "/schemas/core/product-filters.json#/properties/format_kinds" }, - "format_option_refs": { "$ref": "/schemas/core/product-filters.json#/properties/format_option_refs" }, - "standard_formats_only": { "$ref": "/schemas/core/product-filters.json#/properties/standard_formats_only" }, - "min_exposures": { "$ref": "/schemas/core/product-filters.json#/properties/min_exposures" }, - "start_date": { "$ref": "/schemas/core/product-filters.json#/properties/start_date" }, - "end_date": { "$ref": "/schemas/core/product-filters.json#/properties/end_date" }, - "budget_range": { "$ref": "/schemas/core/product-filters.json#/properties/budget_range" }, - "channels": { "$ref": "/schemas/core/product-filters.json#/properties/channels" }, - "video_placement_types": { "$ref": "/schemas/core/product-filters.json#/properties/video_placement_types" }, - "audio_distribution_types": { "$ref": "/schemas/core/product-filters.json#/properties/audio_distribution_types" }, - "sponsored_placement_types": { "$ref": "/schemas/core/product-filters.json#/properties/sponsored_placement_types" }, - "social_placement_surfaces": { "$ref": "/schemas/core/product-filters.json#/properties/social_placement_surfaces" }, - "trusted_match": { "$ref": "/schemas/core/product-filters.json#/properties/trusted_match" }, + "delivery_type": { "$ref": "/schemas/enums/delivery-type.json" }, + "exclusivity": { "$ref": "/schemas/enums/exclusivity.json" }, + "is_fixed_price": { + "type": "boolean", + "description": "Filter fixed-price versus auction offers. Contingent pricing matches neither value." + }, + "pricing_structures": { + "type": "array", + "items": { "$ref": "/schemas/enums/pricing-structure.json" }, + "minItems": 1, + "uniqueItems": true, + "x-canonicalization": "set" + }, + "pricing_currencies": { + "type": "array", + "items": { "type": "string", "pattern": "^[A-Z]{3}$" }, + "minItems": 1, + "uniqueItems": true, + "x-canonicalization": "set" + }, + "format_kinds": { + "type": "array", + "items": { "$ref": "/schemas/core/canonical-format-kind.json" }, + "minItems": 1, + "uniqueItems": true, + "x-canonicalization": "set" + }, + "format_option_refs": { + "type": "array", + "items": { "$ref": "/schemas/core/format-option-ref.json" }, + "minItems": 1, + "uniqueItems": true, + "x-canonicalization": "set" + }, + "standard_formats_only": { "type": "boolean" }, + "min_exposures": { "type": "integer", "minimum": 1 }, + "start_date": { "type": "string", "format": "date" }, + "end_date": { "type": "string", "format": "date" }, + "budget_range": { + "type": "object", + "properties": { + "min": { "type": "number", "minimum": 0 }, + "max": { "type": "number", "minimum": 0 }, + "currency": { "type": "string", "pattern": "^[A-Z]{3}$" } + }, + "required": ["currency"], + "anyOf": [{ "required": ["min"] }, { "required": ["max"] }], + "additionalProperties": true + }, + "channels": { + "type": "array", + "items": { "$ref": "/schemas/enums/channels.json" }, + "minItems": 1 + }, + "video_placement_types": { + "type": "array", + "items": { "$ref": "/schemas/enums/video-placement-type.json" }, + "minItems": 1, + "uniqueItems": true, + "x-canonicalization": "set" + }, + "audio_distribution_types": { + "type": "array", + "items": { "$ref": "/schemas/enums/audio-distribution-type.json" }, + "minItems": 1, + "uniqueItems": true, + "x-canonicalization": "set" + }, + "sponsored_placement_types": { + "type": "array", + "items": { "$ref": "/schemas/enums/sponsored-placement-type.json" }, + "minItems": 1, + "uniqueItems": true, + "x-canonicalization": "set" + }, + "social_placement_surfaces": { + "type": "array", + "items": { "$ref": "/schemas/enums/social-placement-surface.json" }, + "minItems": 1, + "uniqueItems": true, + "x-canonicalization": "set" + }, + "trusted_match": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "agent_url": { "type": "string", "format": "uri" }, + "context_match": { "type": "boolean" }, + "identity_match": { "type": "boolean" } + }, + "required": ["agent_url"], + "additionalProperties": true + }, + "minItems": 1 + }, + "response_types": { + "type": "array", + "items": { "$ref": "/schemas/enums/response-type.json" }, + "minItems": 1 + } + }, + "additionalProperties": false + }, "required_features": { "allOf": [{ "$ref": "/schemas/core/media-buy-features.json" }], "description": "Protocol features the seller must support.", "x-adcp-schema-uri": "/schemas/core/media-buy-features.json" }, "required_performance_standards": { - "allOf": [{ "$ref": "/schemas/core/product-filters.json#/properties/required_performance_standards" }], - "x-adcp-schema-uri": "/schemas/core/product-filters.json#/properties/required_performance_standards" + "type": "array", + "items": { + "type": "object", + "properties": { + "metric": { "$ref": "/schemas/enums/performance-standard-metric.json" }, + "threshold": { "type": "number", "minimum": 0, "maximum": 1 }, + "standard": { "$ref": "/schemas/enums/viewability-standard.json" }, + "vendor": { "$ref": "/schemas/core/brand-key.json" } + }, + "required": ["metric", "threshold", "vendor"], + "additionalProperties": true + }, + "minItems": 1 + }, + "required_metrics": { + "type": "array", + "items": { "$ref": "/schemas/enums/available-metric.json" }, + "minItems": 1, + "uniqueItems": true }, - "required_metrics": { "$ref": "/schemas/core/product-filters.json#/properties/required_metrics" }, "required_vendor_metrics": { - "allOf": [{ "$ref": "/schemas/core/product-filters.json#/properties/required_vendor_metrics" }], - "x-adcp-schema-uri": "/schemas/core/product-filters.json#/properties/required_vendor_metrics" + "type": "array", + "items": { + "type": "object", + "properties": { + "vendor": { "$ref": "/schemas/core/brand-key.json" }, + "metric_id": { "$ref": "/schemas/core/vendor-metric-id.json" } + }, + "anyOf": [{ "required": ["vendor"] }, { "required": ["metric_id"] }], + "additionalProperties": false + }, + "minItems": 1 }, "audience_evidence_requirements": { - "allOf": [{ "$ref": "/schemas/core/audience-evidence-requirements.json" }], - "description": "Buyer policy for evaluating published audience evidence.", - "x-adcp-schema-uri": "/schemas/core/audience-evidence-requirements.json" + "$ref": "/schemas/core/product-audience-evidence-requirements.json" }, "ext": { "$ref": "/schemas/core/ext.json" } }, diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index 9a69e71757..785efb4abb 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -538,6 +538,10 @@ "$ref": "/schemas/core/product-offer-filters.json", "description": "Offer-only product filters used by the compact product-discovery tools" }, + "product-audience-evidence-requirements": { + "$ref": "/schemas/core/product-audience-evidence-requirements.json", + "description": "Reference-only audience evidence policy used by compact product discovery" + }, "creative-filters": { "$ref": "/schemas/core/creative-filters.json", "description": "Filter criteria for querying creative assets from the centralized library" diff --git a/tests/mcp-schema-projection.test.cjs b/tests/mcp-schema-projection.test.cjs index 107d1cf302..085f1d5ee3 100644 --- a/tests/mcp-schema-projection.test.cjs +++ b/tests/mcp-schema-projection.test.cjs @@ -427,6 +427,21 @@ test('generated MCP projection covers every tool within AdCP safety bounds', () 'generic get_task_status output should not embed the global task-result union' ); + for (const toolName of [ + 'list_products', + 'request_proposals', + 'refine_proposals', + 'decline_proposals', + ]) { + const tool = projectionManifest.tools[toolName]; + const input = JSON.stringify(readJson(path.join(PROJECTION_DIR, tool.inputSchema))); + const output = JSON.stringify(readJson(path.join(PROJECTION_DIR, tool.outputSchema))); + assert.doesNotMatch(input, /(?:Provenance|provenance\.json|AssetVariant|asset-variant\.json)/, + `${toolName} input must not depend on the creative provenance graph`); + assert.doesNotMatch(output, /(?:AssetVariant|asset-variant\.json)/, + `${toolName} output must not depend on creative asset variants`); + } + const seen = new Set(); const paritySchemas = new Map(); let totalBytes = 0; From 58bf79abf414504cd1371ab5ef51bf9042a902e9 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 12 Aug 2026 05:12:00 +0200 Subject: [PATCH 34/49] keep split product tools canonical-only --- .changeset/secure-get-products-idempotency.md | 2 +- .../source/core/canonical-product.json | 37 ++++++++++ static/schemas/source/index.json | 4 ++ .../media-buy/list-products-response.json | 4 +- .../source/media-buy/product-fields.json | 3 +- .../media-buy/refine-proposals-response.json | 4 +- .../media-buy/request-proposals-response.json | 4 +- tests/composed-schema-validation.test.cjs | 72 ++++++++++++++++++- tests/mcp-schema-projection.test.cjs | 48 +++++++++++++ 9 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 static/schemas/source/core/canonical-product.json diff --git a/.changeset/secure-get-products-idempotency.md b/.changeset/secure-get-products-idempotency.md index 6e4e791b8e..dcebd627c0 100644 --- a/.changeset/secure-get-products-idempotency.md +++ b/.changeset/secure-get-products-idempotency.md @@ -2,4 +2,4 @@ "adcontextprotocol": minor --- -Add the compact AdCP 3.2 product lifecycle: `list_products`, `request_proposals`, `refine_proposals`, and `decline_proposals`. The task-specific contracts separate offer discovery from executable proposal creation, immutable refinement, terminal decline, and purchase while retaining `get_products` as a key-optional compatibility facade throughout 3.x. A shared opportunity reference connects planning-cycle context without duplicating proposal version identity. Product-discovery inputs use stable brand keys and an offer-only filter graph, preventing buyer calls from transitively embedding brand assets or content provenance. Publish machine-readable SDK fallback grades and task-result schema resolution, plus an MCP production profile that filters out compliance-only and deprecated tools and removes presentation annotations without changing validation semantics. +Add the compact AdCP 3.2 product lifecycle: `list_products`, `request_proposals`, `refine_proposals`, and `decline_proposals`. The task-specific contracts separate offer discovery from executable proposal creation, immutable refinement, terminal decline, and purchase while retaining `get_products` as a key-optional compatibility facade throughout 3.x. A shared opportunity reference connects planning-cycle context without duplicating proposal version identity. Product-discovery inputs use stable brand keys and an offer-only filter graph, preventing buyer calls from transitively embedding brand assets or content provenance. Split-tool product responses are canonical-only: they require `format_options` and reject legacy product or placement `format_ids` and `v1_format_ref` compatibility links. Publish machine-readable SDK fallback grades and task-result schema resolution, plus an MCP production profile that filters out compliance-only and deprecated tools and removes presentation annotations without changing validation semantics. diff --git a/static/schemas/source/core/canonical-product.json b/static/schemas/source/core/canonical-product.json new file mode 100644 index 0000000000..1b3897a310 --- /dev/null +++ b/static/schemas/source/core/canonical-product.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-product.json", + "title": "Canonical Product", + "description": "Canonical-only Product view returned by the AdCP 3.2 split product and proposal tools. It requires format_options and forbids legacy named-format identifiers at the product, placement, and format-option levels. The get_products compatibility facade remains the only product-discovery surface that may emit those legacy creative declarations.", + "allOf": [ + { "$ref": "/schemas/core/product.json" }, + { + "type": "object", + "properties": { + "format_options": { + "type": "array", + "items": { + "not": { "required": ["v1_format_ref"] } + } + }, + "placements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "format_options": { + "type": "array", + "items": { + "not": { "required": ["v1_format_ref"] } + } + } + }, + "not": { "required": ["format_ids"] } + } + } + }, + "required": ["format_options"], + "not": { "required": ["format_ids"] } + } + ] +} diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index 785efb4abb..c1c8627ea7 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -45,6 +45,10 @@ "$ref": "/schemas/core/product.json", "description": "Represents available advertising inventory" }, + "canonical-product": { + "$ref": "/schemas/core/canonical-product.json", + "description": "Canonical-only product view for the AdCP 3.2 split product and proposal tools" + }, "media-buy": { "$ref": "/schemas/core/media-buy.json", "description": "Represents a purchased advertising campaign" diff --git a/static/schemas/source/media-buy/list-products-response.json b/static/schemas/source/media-buy/list-products-response.json index 22d09015f0..9fa9f5198b 100644 --- a/static/schemas/source/media-buy/list-products-response.json +++ b/static/schemas/source/media-buy/list-products-response.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/list-products-response.json", "title": "List Products Response", - "description": "Product offers and continuation state. This response never contains proposals or proposal-lifecycle fields.", + "description": "Canonical product offers and continuation state. Products require format_options and never carry legacy named-format identifiers. This response never contains proposals or proposal-lifecycle fields.", "type": "object", "discriminator": { "propertyName": "outcome" @@ -15,7 +15,7 @@ }, "products": { "type": "array", - "items": { "$ref": "/schemas/core/product.json" } + "items": { "$ref": "/schemas/core/canonical-product.json" } }, "next_cursor": { "type": "string", diff --git a/static/schemas/source/media-buy/product-fields.json b/static/schemas/source/media-buy/product-fields.json index 0e7f310b16..6d03f1b72a 100644 --- a/static/schemas/source/media-buy/product-fields.json +++ b/static/schemas/source/media-buy/product-fields.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/product-fields.json", "title": "Product Response Fields", - "description": "Product fields a buyer requests in a product-discovery response. Required product_id and name fields are always returned.", + "description": "Canonical product fields a buyer requests from list_products. Required product_id and name fields are always returned. Legacy named-format fields remain available only through get_products.", "type": "array", "minItems": 1, "uniqueItems": true, @@ -18,7 +18,6 @@ "audio_distribution_types", "sponsored_placement_types", "social_placement_surfaces", - "format_ids", "format_options", "placements", "delivery_type", diff --git a/static/schemas/source/media-buy/refine-proposals-response.json b/static/schemas/source/media-buy/refine-proposals-response.json index 864abb41bf..ad9216aa7a 100644 --- a/static/schemas/source/media-buy/refine-proposals-response.json +++ b/static/schemas/source/media-buy/refine-proposals-response.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/refine-proposals-response.json", "title": "Refine Proposals Response", - "description": "One ordered immutable revision result per requested source proposal, plus the products needed to evaluate the revised allocations.", + "description": "One ordered immutable revision result per requested source proposal, plus the canonical products needed to evaluate the revised allocations. Products require format_options and never carry legacy named-format identifiers.", "type": "object", "properties": { "results": { @@ -71,7 +71,7 @@ }, "products": { "type": "array", - "items": { "$ref": "/schemas/core/product.json" } + "items": { "$ref": "/schemas/core/canonical-product.json" } } }, "required": ["results", "products"], diff --git a/static/schemas/source/media-buy/request-proposals-response.json b/static/schemas/source/media-buy/request-proposals-response.json index 4e170aaf96..deeb40b45b 100644 --- a/static/schemas/source/media-buy/request-proposals-response.json +++ b/static/schemas/source/media-buy/request-proposals-response.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/request-proposals-response.json", "title": "Request Proposals Response", - "description": "One or more executable media-plan proposals and the exact products referenced by their allocations. Returning only products does not satisfy request_proposals.", + "description": "One or more executable media-plan proposals and the exact canonical products referenced by their allocations. Products require format_options and never carry legacy named-format identifiers. Returning only products does not satisfy request_proposals.", "type": "object", "discriminator": { "propertyName": "outcome" @@ -34,7 +34,7 @@ "products": { "type": "array", "minItems": 1, - "items": { "$ref": "/schemas/core/product.json" } + "items": { "$ref": "/schemas/core/canonical-product.json" } } }, "oneOf": [ diff --git a/tests/composed-schema-validation.test.cjs b/tests/composed-schema-validation.test.cjs index a6955faf29..5d8e3272d5 100644 --- a/tests/composed-schema-validation.test.cjs +++ b/tests/composed-schema-validation.test.cjs @@ -2823,6 +2823,12 @@ async function runTests() { date_range_support: 'date_range' } }; + const canonicalProductBase = structuredClone(productBase); + delete canonicalProductBase.format_ids; + canonicalProductBase.format_options = [{ + format_kind: 'image', + params: { width: 300, height: 250 } + }]; const productSignalOption = { signal_ref: { scope: 'product', signal_id: 'high_intent_shoppers' }, name: 'High intent shoppers', @@ -3026,6 +3032,11 @@ async function runTests() { { fields: ['product_id', 'pricing_options'] }, 'list_products is a key-optional synchronous read' ); + await testSchemaRejection( + '/schemas/media-buy/list-products-request.json', + { fields: ['product_id', 'format_ids'] }, + 'list_products does not expose the legacy named-format field selector' + ); await testSchemaValidation( '/schemas/media-buy/list-products-request.json', { @@ -3235,6 +3246,63 @@ async function runTests() { { outcome: 'listed', products: [] }, 'list_products treats no matches as an empty successful product page' ); + await testSchemaValidation( + '/schemas/core/canonical-product.json', + canonicalProductBase, + 'split product tools accept canonical format options' + ); + await testSchemaRejection( + '/schemas/core/canonical-product.json', + productBase, + 'split product tools reject legacy-only product format IDs' + ); + await testSchemaRejection( + '/schemas/core/canonical-product.json', + { ...canonicalProductBase, format_ids: productBase.format_ids }, + 'split product tools reject dual-emitted legacy product format IDs' + ); + await testSchemaRejection( + '/schemas/core/canonical-product.json', + { + ...canonicalProductBase, + format_options: [{ + ...canonicalProductBase.format_options[0], + v1_format_ref: productBase.format_ids + }] + }, + 'split product tools reject legacy named-format links inside canonical options' + ); + await testSchemaRejection( + '/schemas/core/canonical-product.json', + { + ...canonicalProductBase, + placements: [{ + kind: 'seller_inline', + placement_id: 'homepage', + name: 'Homepage', + mode: 'targetable', + format_ids: productBase.format_ids + }] + }, + 'split product tools reject legacy placement format IDs' + ); + await testSchemaRejection( + '/schemas/core/canonical-product.json', + { + ...canonicalProductBase, + placements: [{ + kind: 'seller_inline', + placement_id: 'homepage', + name: 'Homepage', + mode: 'targetable', + format_options: [{ + ...canonicalProductBase.format_options[0], + v1_format_ref: productBase.format_ids + }] + }] + }, + 'split product tools reject legacy named-format links inside placement options' + ); await testSchemaValidation( '/schemas/media-buy/list-products-response.json', { outcome: 'unchanged', feed_version: 'feed-v2', cache_scope: 'public' }, @@ -3251,7 +3319,7 @@ async function runTests() { proposal_status: 'committed', expires_at: '2027-06-30T23:59:59Z' }], - products: [{ ...productBase, product_id: 'premium-video' }] + products: [{ ...canonicalProductBase, product_id: 'premium-video' }] }, 'request_proposals explicitly discriminates a successful proposal response' ); @@ -3280,7 +3348,7 @@ async function runTests() { expires_at: '2027-06-30T23:59:59Z' }], outcome: 'proposed', - products: [{ ...productBase, product_id: 'premium-video' }], + products: [{ ...canonicalProductBase, product_id: 'premium-video' }], reason: 'This must not appear on the success arm.' }, 'request_proposals success cannot carry rejection fields' diff --git a/tests/mcp-schema-projection.test.cjs b/tests/mcp-schema-projection.test.cjs index 085f1d5ee3..53e7a6c80c 100644 --- a/tests/mcp-schema-projection.test.cjs +++ b/tests/mcp-schema-projection.test.cjs @@ -438,10 +438,58 @@ test('generated MCP projection covers every tool within AdCP safety bounds', () const output = JSON.stringify(readJson(path.join(PROJECTION_DIR, tool.outputSchema))); assert.doesNotMatch(input, /(?:Provenance|provenance\.json|AssetVariant|asset-variant\.json)/, `${toolName} input must not depend on the creative provenance graph`); + assert.doesNotMatch(input, /format_ids|format-id\.json|v1_format_ref/, + `${toolName} input must not expose legacy named-format creatives`); assert.doesNotMatch(output, /(?:AssetVariant|asset-variant\.json)/, `${toolName} output must not depend on creative asset variants`); + if (toolName !== 'decline_proposals') { + assert.match(output, /Canonical Product/, + `${toolName} output must use the canonical-only Product view`); + } } + const projectedListProductsOutput = readJson(path.join( + PROJECTION_DIR, + projectionManifest.tools.list_products.outputSchema + )); + const validateProjectedListProducts = createValidator(Ajv2020).compile(projectedListProductsOutput); + const projectedProductBase = { + product_id: 'canonical-product-1', + name: 'Canonical product', + description: 'Projection boundary fixture', + publisher_properties: [{ publisher_domain: 'publisher.example', selection_type: 'all' }], + delivery_type: 'guaranteed', + pricing_options: [{ + pricing_option_id: 'cpm', + pricing_model: 'cpm', + rate: 10, + currency: 'USD', + is_fixed: true, + }], + reporting_capabilities: { + available_reporting_frequencies: ['daily'], + expected_delay_minutes: 240, + timezone: 'UTC', + supports_webhooks: false, + available_metrics: ['impressions'], + date_range_support: 'date_range', + }, + }; + assert.equal(validateProjectedListProducts({ + outcome: 'listed', + products: [{ + ...projectedProductBase, + format_options: [{ format_kind: 'image', params: { width: 300, height: 250 } }], + }], + }), true, JSON.stringify(validateProjectedListProducts.errors)); + assert.equal(validateProjectedListProducts({ + outcome: 'listed', + products: [{ + ...projectedProductBase, + format_ids: [{ agent_url: 'https://legacy-creative.example', id: 'display_300x250' }], + }], + }), false, 'projected list_products output accepted a legacy-only creative declaration'); + const seen = new Set(); const paritySchemas = new Map(); let totalBytes = 0; From 6c033bab42ede3b46083c29b35e366a5f2b6f1d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 07:14:36 +0000 Subject: [PATCH 35/49] fix(media-buy): address review findings from PR #6115 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1 (blocker): Revise `committed` enumDescription to document the normative rejection path — a seller that did not reserve inventory MAY reject an unexpired committed proposal at create_media_buy via PROPOSAL_UNAVAILABLE; availability risk pre-execution is on the buyer absent a stated hold. Add PROPOSAL_UNAVAILABLE error code with correctable recovery guidance and regenerate compliance-error-codes.mdx. Add matching PROPOSAL_UNAVAILABLE note to the create_media_buy doc page. Finding 2 (well-founded): Add `countries` and `property_list` product-attribute filters to ProductOfferFilters (channels was already present). Override `incomplete` description on list-products-response to clarify it fires on result-count truncation at the page cap, not time_budget (which has no meaning for a sync task). Add dead-surface note to push_notification_config on list-products-request. Finding 3 (blocker): Create per-task async submitted schemas for request_proposals and refine_proposals, register both in the webhook union anyOf. Add one-line MAY-async statement to each doc page. Finding 4 (well-founded): Flip all four new task pages (list_products, request_proposals, refine_proposals, decline_proposals) to testable: false pending behavioral compliance storyboard work (tracked in #6432). --- .changeset/secure-get-products-idempotency.md | 2 + .../task-reference/create_media_buy.mdx | 2 + .../task-reference/decline_proposals.mdx | 2 +- .../task-reference/list_products.mdx | 2 +- .../task-reference/refine_proposals.mdx | 4 +- .../task-reference/request_proposals.mdx | 4 +- docs/snippets/compliance-error-codes.mdx | 10 ++++ .../source/core/async-response-data.json | 10 ++++ .../source/core/product-offer-filters.json | 13 +++++ static/schemas/source/enums/error-code.json | 6 +++ .../schemas/source/enums/proposal-status.json | 2 +- .../media-buy/list-products-request.json | 1 + .../media-buy/list-products-response.json | 6 ++- ...ne-proposals-async-response-submitted.json | 51 +++++++++++++++++++ ...st-proposals-async-response-submitted.json | 51 +++++++++++++++++++ 15 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 static/schemas/source/media-buy/refine-proposals-async-response-submitted.json create mode 100644 static/schemas/source/media-buy/request-proposals-async-response-submitted.json diff --git a/.changeset/secure-get-products-idempotency.md b/.changeset/secure-get-products-idempotency.md index dcebd627c0..d475fbdeff 100644 --- a/.changeset/secure-get-products-idempotency.md +++ b/.changeset/secure-get-products-idempotency.md @@ -3,3 +3,5 @@ --- Add the compact AdCP 3.2 product lifecycle: `list_products`, `request_proposals`, `refine_proposals`, and `decline_proposals`. The task-specific contracts separate offer discovery from executable proposal creation, immutable refinement, terminal decline, and purchase while retaining `get_products` as a key-optional compatibility facade throughout 3.x. A shared opportunity reference connects planning-cycle context without duplicating proposal version identity. Product-discovery inputs use stable brand keys and an offer-only filter graph, preventing buyer calls from transitively embedding brand assets or content provenance. Split-tool product responses are canonical-only: they require `format_options` and reject legacy product or placement `format_ids` and `v1_format_ref` compatibility links. Publish machine-readable SDK fallback grades and task-result schema resolution, plus an MCP production profile that filters out compliance-only and deprecated tools and removes presentation annotations without changing validation semantics. + +Revise `committed` proposal-status semantics to document the normative rejection path: a seller that did not reserve inventory MAY reject an unexpired committed proposal at `create_media_buy` via `PROPOSAL_UNAVAILABLE`; availability risk pre-execution is on the buyer absent a stated hold. Add `PROPOSAL_UNAVAILABLE` error code with correctable recovery guidance. Extend `ProductOfferFilters` with `countries` and `property_list` product-attribute filters. Add async submitted schemas and webhook-union entries for `request_proposals` and `refine_proposals`; both tasks now document a MAY-async path for consultative planning and complex re-underwriting scenarios. diff --git a/docs/media-buy/task-reference/create_media_buy.mdx b/docs/media-buy/task-reference/create_media_buy.mdx index e5cc166897..448d335e91 100644 --- a/docs/media-buy/task-reference/create_media_buy.mdx +++ b/docs/media-buy/task-reference/create_media_buy.mdx @@ -162,6 +162,8 @@ npx @adcp/sdk@latest \ When executing a proposal, `proposal_status` on the returned proposal determines whether `create_media_buy` is valid. The split 3.2 request and refine tasks return `committed` snapshots that can be executed before `expires_at`; `create_media_buy` is the buyer acceptance/execution step. Legacy [`get_products`](/docs/media-buy/task-reference/get_products) drafts require its finalize form, which remains supported throughout 3.x. +A seller that did not reserve inventory at proposal time MAY reject an unexpired `committed` proposal at execution with [`PROPOSAL_UNAVAILABLE`](/docs/building/verification/compliance-catalog#error-code-proposal-unavailable). Inventory availability risk pre-execution is on the buyer unless the seller explicitly commits a hold in the proposal terms. Buyers that need reservation guarantees should request a hold in the `request_proposals` brief. + ### TotalBudget Object | Parameter | Type | Required | Description | diff --git a/docs/media-buy/task-reference/decline_proposals.mdx b/docs/media-buy/task-reference/decline_proposals.mdx index fd6a97f57d..dad33e452f 100644 --- a/docs/media-buy/task-reference/decline_proposals.mdx +++ b/docs/media-buy/task-reference/decline_proposals.mdx @@ -2,7 +2,7 @@ title: decline_proposals description: "Terminally decline one or more immutable proposals." "og:title": "AdCP decline_proposals Task" -testable: true +testable: false --- `decline_proposals` records that the buyer evaluated one or more proposals and will not pursue them. The operation is terminal: a seller must reject later [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) or new logical [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) attempts for a declined proposal with [`INVALID_STATE`](/docs/building/verification/compliance-catalog#error-code-invalid-state). Exact retries of an earlier successful call still follow the shared idempotency replay contract. A proposal that was already executed cannot subsequently be declined and returns `unable`. diff --git a/docs/media-buy/task-reference/list_products.mdx b/docs/media-buy/task-reference/list_products.mdx index d964e94866..3bae019056 100644 --- a/docs/media-buy/task-reference/list_products.mdx +++ b/docs/media-buy/task-reference/list_products.mdx @@ -2,7 +2,7 @@ title: list_products description: "Read seller product offers with structured discovery criteria." "og:title": "AdCP list_products Task" -testable: true +testable: false --- `list_products` is the side-effect-free entry point to the compact product lifecycle. It returns products only—never proposals—and always completes synchronously. Buyers can purchase a listed product directly or pass returned `product_id` values to [`request_proposals`](/docs/media-buy/task-reference/request_proposals) for seller planning. diff --git a/docs/media-buy/task-reference/refine_proposals.mdx b/docs/media-buy/task-reference/refine_proposals.mdx index 04e91100dd..d37d730433 100644 --- a/docs/media-buy/task-reference/refine_proposals.mdx +++ b/docs/media-buy/task-reference/refine_proposals.mdx @@ -2,7 +2,7 @@ title: refine_proposals description: "Create executable revisions from one or more proposals." "og:title": "AdCP refine_proposals Task" -testable: true +testable: false --- `refine_proposals` creates executable committed revisions directly. Each entry targets one proposal, proposal IDs must be unique within the request, and results preserve request order. Successful refinement returns a new `proposal_id`; that ID is the complete version identity for the immutable commercial snapshot, and the source remains addressable for comparison or parallel exploration. @@ -28,3 +28,5 @@ testable: true The response contains one ordered `results[]` entry per request. `revised` means the requested change was applied, `partial` returns an honest executable revision plus explanatory notes, and `unable` returns a reason without minting a misleading revision. Every returned revision is an immutable committed snapshot that can be passed directly to [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy), or to [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals) when the buyer stops pursuing it. Every revised proposal inherits the source proposal's opportunity association. Callers do not repeat opportunity context during refinement. + +Sellers MAY respond asynchronously with `status: "submitted"` when a complex multi-publisher revision requires upstream pricing queries or manual re-underwriting. In that case the response contains a `task_id` for polling via [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json); terminal revised proposals are delivered on the completion artifact or via push notification if `push_notification_config` was supplied. diff --git a/docs/media-buy/task-reference/request_proposals.mdx b/docs/media-buy/task-reference/request_proposals.mdx index 64a38bad31..ac88100202 100644 --- a/docs/media-buy/task-reference/request_proposals.mdx +++ b/docs/media-buy/task-reference/request_proposals.mdx @@ -2,7 +2,7 @@ title: request_proposals description: "Request one or more actionable seller-authored media plans." "og:title": "AdCP request_proposals Task" -testable: true +testable: false --- `request_proposals` creates one or more executable media-plan proposal snapshots from a brief. It can begin a consultative workflow directly or use `product_ids` returned by [`list_products`](/docs/media-buy/task-reference/list_products). Success always contains at least one proposal; returning products without a proposal does not satisfy this task. @@ -33,3 +33,5 @@ Every returned allocation references the exact `product_id` whose pricing and fo `opportunity` is optional shared planning-cycle context and must be open when supplied here. Its buyer-assigned `opportunity_id` can span request, decline, and purchase calls without becoming part of proposal identity. Sellers associate it with every proposal created by the request, and revised proposals inherit the same association. The response uses `outcome: "proposed"` for a successful executable set and `outcome: "rejected"` with a reason when the seller cannot construct an actionable plan. + +Sellers MAY respond asynchronously with `status: "submitted"` when consultative planning requires upstream system queries or human sales-desk review. In that case the response contains a `task_id` for polling via [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json); terminal proposals are delivered on the completion artifact or via push notification if `push_notification_config` was supplied. diff --git a/docs/snippets/compliance-error-codes.mdx b/docs/snippets/compliance-error-codes.mdx index dbdd26305f..d10b85ff1a 100644 --- a/docs/snippets/compliance-error-codes.mdx +++ b/docs/snippets/compliance-error-codes.mdx @@ -86,6 +86,7 @@ description: "Canonical AdCP error codes with recovery classifications, remediat | `PROPOSAL_EXPIRED` | correctable | call request_proposals for a fresh executable proposal, or re-discover through legacy get_products | | `PROPOSAL_NOT_COMMITTED` | correctable | finalize the legacy draft through get_products, or request a fresh executable proposal | | `PROPOSAL_NOT_FOUND` | correctable | request a fresh executable proposal, then retry | +| `PROPOSAL_UNAVAILABLE` | correctable | call request_proposals for a fresh executable proposal; consider requesting a hold in the brief if inventory reservation is required | | `PROVENANCE_CLAIM_CONTRADICTED` | correctable | revise the provenance claim to match the verifier's observation or replace the creative; auto-retry without correction will not pass | | `PROVENANCE_DIGITAL_SOURCE_TYPE_MISSING` | correctable | set provenance.digital_source_type to a value from the digital-source-type enum and resubmit | | `PROVENANCE_DISCLOSURE_MISSING` | correctable | set provenance.disclosure.required and, when true, populate disclosure.jurisdictions | @@ -842,6 +843,15 @@ The referenced legacy get_products proposal has proposal_status 'draft' and cann The referenced proposal_id is not recognized by the seller — it belongs to a different tenant, was never issued, or was evicted from the seller's session cache before consumption. Distinct from {"PROPOSAL_EXPIRED"} (a known proposal whose {"expires_at"} window has passed) and {"PROPOSAL_NOT_COMMITTED"} (a known legacy get_products proposal still in {"draft"}). Recovery: correctable (request a fresh executable proposal, then retry create_media_buy). + + + + + +**Suggested action:** call request_proposals for a fresh executable proposal; consider requesting a hold in the brief if inventory reservation is required + +An unexpired committed proposal cannot be executed because inventory is no longer available and the seller did not reserve it at proposal time. The proposal was valid when issued; the seller has not reserved the inventory and the available slots have since sold. Distinct from {"PROPOSAL_EXPIRED"} (expires_at window passed) and {"PROPOSAL_NOT_FOUND"} (unknown proposal_id). Recovery: correctable (call request_proposals to obtain a fresh proposal; consider asking the seller about hold options in the request brief). + diff --git a/static/schemas/source/core/async-response-data.json b/static/schemas/source/core/async-response-data.json index 6026f2c092..6e10b05353 100644 --- a/static/schemas/source/core/async-response-data.json +++ b/static/schemas/source/core/async-response-data.json @@ -34,11 +34,21 @@ "description": "Terminal response for request_proposals", "$ref": "/schemas/media-buy/request-proposals-response.json" }, + { + "title": "RequestProposalsAsyncSubmitted", + "description": "Acknowledgment for submitted request_proposals (consultative planning requiring upstream queries or human review)", + "$ref": "/schemas/media-buy/request-proposals-async-response-submitted.json" + }, { "title": "RefineProposalsResponse", "description": "Terminal response for refine_proposals", "$ref": "/schemas/media-buy/refine-proposals-response.json" }, + { + "title": "RefineProposalsAsyncSubmitted", + "description": "Acknowledgment for submitted refine_proposals (complex revision requiring re-underwriting or upstream pricing queries)", + "$ref": "/schemas/media-buy/refine-proposals-async-response-submitted.json" + }, { "title": "DeclineProposalsResponse", "description": "Terminal response for decline_proposals", diff --git a/static/schemas/source/core/product-offer-filters.json b/static/schemas/source/core/product-offer-filters.json index 8382ba6456..de049d8057 100644 --- a/static/schemas/source/core/product-offer-filters.json +++ b/static/schemas/source/core/product-offer-filters.json @@ -54,6 +54,19 @@ "anyOf": [{ "required": ["min"] }, { "required": ["max"] }], "additionalProperties": true }, + "countries": { + "type": "array", + "description": "Filter by country coverage using ISO 3166-1 alpha-2 codes (e.g., ['US', 'CA', 'GB']). Returns products whose geographic coverage includes at least one of the specified countries. This is a product attribute filter, not a delivery-targeting instruction.", + "items": { + "type": "string", + "pattern": "^[A-Z]{2}$" + }, + "minItems": 1 + }, + "property_list": { + "$ref": "/schemas/core/property-list-ref.json", + "description": "Reference to an externally managed property list. When provided, the seller filters products to only those available on properties in the list. This narrows which publisher inventory is returned; it is a product attribute filter, not a delivery-targeting instruction." + }, "channels": { "type": "array", "items": { "$ref": "/schemas/enums/channels.json" }, diff --git a/static/schemas/source/enums/error-code.json b/static/schemas/source/enums/error-code.json index 42c6343cc5..458da452fc 100644 --- a/static/schemas/source/enums/error-code.json +++ b/static/schemas/source/enums/error-code.json @@ -58,6 +58,7 @@ "PRODUCT_EXPIRED", "PROPOSAL_NOT_COMMITTED", "PROPOSAL_NOT_FOUND", + "PROPOSAL_UNAVAILABLE", "MULTI_FINALIZE_UNSUPPORTED", "IO_REQUIRED", "TERMS_REJECTED", @@ -158,6 +159,7 @@ "PRODUCT_EXPIRED": "One or more referenced products have passed their expires_at timestamp and are no longer available for purchase. Recovery: correctable (re-discover with get_products to find current inventory).", "PROPOSAL_NOT_COMMITTED": "The referenced legacy get_products proposal has proposal_status 'draft' and cannot be used to create a media buy. Recovery: correctable (finalize it through get_products first). AdCP 3.2 request_proposals and refine_proposals return executable committed snapshots.", "PROPOSAL_NOT_FOUND": "The referenced proposal_id is not recognized by the seller — it belongs to a different tenant, was never issued, or was evicted from the seller's session cache before consumption. Distinct from `PROPOSAL_EXPIRED` (a known proposal whose `expires_at` window has passed) and `PROPOSAL_NOT_COMMITTED` (a known legacy get_products proposal still in `draft`). Recovery: correctable (request a fresh executable proposal, then retry create_media_buy).", + "PROPOSAL_UNAVAILABLE": "An unexpired committed proposal cannot be executed because inventory is no longer available and the seller did not reserve it at proposal time. The proposal was valid when issued; the seller has not reserved the inventory and the available slots have since sold. Distinct from `PROPOSAL_EXPIRED` (expires_at window passed) and `PROPOSAL_NOT_FOUND` (unknown proposal_id). Recovery: correctable (call request_proposals to obtain a fresh proposal; consider asking the seller about hold options in the request brief).", "MULTI_FINALIZE_UNSUPPORTED": "Returned by sellers that cannot guarantee atomic commit across multiple proposals in a single `get_products` call (multiple `action: 'finalize'` entries in `refine[]` targeting different `proposal_id` values). The buyer's intent — atomic multi-proposal finalize — is structurally well-formed and per spec atomic, but this seller's downstream stack cannot satisfy the atomicity guarantee (e.g., the proposals route to two different ad servers with no 2PC). More specific than `INVALID_REQUEST` so buyers can distinguish 'this seller doesn't support multi-finalize' from 'the request itself is malformed'. Recovery: correctable (sequence the finalize calls one-`proposal_id`-per-call; there is no capability flag for multi-finalize support, so a successful first-attempt is the only positive discovery signal). See [refinement guide § Finalize is exclusive](/docs/media-buy/product-discovery/refinement#finalize-is-exclusive-within-refine).", "IO_REQUIRED": "The committed proposal requires a signed insertion order but no io_acceptance was provided. Recovery: correctable (review the proposal's insertion_order, accept terms, and include io_acceptance on create_media_buy).", "TERMS_REJECTED": "Buyer-proposed measurement_terms were rejected by the seller. The error details SHOULD identify which specific term was rejected and the seller's acceptable range or supported vendors. Recovery: correctable (adjust the proposed terms and retry, or omit measurement_terms to accept the product's defaults).", @@ -418,6 +420,10 @@ "recovery": "correctable", "suggestion": "request a fresh executable proposal, then retry" }, + "PROPOSAL_UNAVAILABLE": { + "recovery": "correctable", + "suggestion": "call request_proposals for a fresh executable proposal; consider requesting a hold in the brief if inventory reservation is required" + }, "MULTI_FINALIZE_UNSUPPORTED": { "recovery": "correctable", "suggestion": "sequence single-proposal finalize calls — one finalize entry per get_products call" diff --git a/static/schemas/source/enums/proposal-status.json b/static/schemas/source/enums/proposal-status.json index f3abc730ca..c6e45a5748 100644 --- a/static/schemas/source/enums/proposal-status.json +++ b/static/schemas/source/enums/proposal-status.json @@ -10,6 +10,6 @@ ], "enumDescriptions": { "draft": "Indicative pricing and availability. The buyer can compare and plan but must finalize before purchasing. Use the 'finalize' refine action to request firm pricing and any inventory hold.", - "committed": "Firm, executable terms. The buyer can execute this proposal via create_media_buy before expires_at. The status does not by itself promise that inventory is reserved; a seller that provides a hold must state it in the proposal terms. Execution is buyer acceptance. After expires_at, the buyer must request or discover a fresh proposal." + "committed": "Firm, executable terms. The buyer can execute this proposal via create_media_buy before expires_at. The status does not by itself promise that inventory is reserved; a seller that provides a hold must state it in the proposal terms. A seller that has not reserved inventory MAY reject an unexpired committed proposal at the create_media_buy step via PROPOSAL_UNAVAILABLE; in that case, availability risk pre-execution is on the buyer. Execution is buyer acceptance. After expires_at, the buyer must request or discover a fresh proposal." } } diff --git a/static/schemas/source/media-buy/list-products-request.json b/static/schemas/source/media-buy/list-products-request.json index d194504217..363f6d9172 100644 --- a/static/schemas/source/media-buy/list-products-request.json +++ b/static/schemas/source/media-buy/list-products-request.json @@ -25,6 +25,7 @@ "governance_context": { "type": "string", "minLength": 1, "maxLength": 4096 }, "push_notification_config": { "allOf": [{ "$ref": "/schemas/core/push-notification-config.json" }], + "description": "Reserved surface; list_products is synchronous and does not dispatch push notifications. Sellers MUST ignore this field. It is accepted without error so buyers can send a uniform request envelope. If a future amendment makes list_products async, this field will become active.", "x-adcp-schema-uri": "/schemas/core/push-notification-config.json" }, "account_id": { diff --git a/static/schemas/source/media-buy/list-products-response.json b/static/schemas/source/media-buy/list-products-response.json index 9fa9f5198b..c26a95568e 100644 --- a/static/schemas/source/media-buy/list-products-response.json +++ b/static/schemas/source/media-buy/list-products-response.json @@ -25,7 +25,11 @@ "feed_version": { "type": "string", "description": "Opaque version of the selected offer feed." }, "pricing_version": { "type": "string", "description": "Opaque version of the selected pricing layer." }, "cache_scope": { "type": "string", "enum": ["public", "account"] }, - "incomplete": { "$ref": "/schemas/media-buy/get-products-response.json#/properties/incomplete" } + "incomplete": { + "allOf": [{ "$ref": "/schemas/media-buy/get-products-response.json#/properties/incomplete" }], + "description": "Declares that the result set was truncated because the page cap (max_results) was reached before all matching products could be returned. time_budget has no meaning for this synchronous task and MUST NOT be used as a trigger condition. When incomplete is present, callers should paginate via next_cursor to retrieve remaining products.", + "x-adcp-schema-uri": "/schemas/media-buy/get-products-response.json#/properties/incomplete" + } }, "oneOf": [ { diff --git a/static/schemas/source/media-buy/refine-proposals-async-response-submitted.json b/static/schemas/source/media-buy/refine-proposals-async-response-submitted.json new file mode 100644 index 0000000000..b75f5c3c7c --- /dev/null +++ b/static/schemas/source/media-buy/refine-proposals-async-response-submitted.json @@ -0,0 +1,51 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/refine-proposals-async-response-submitted.json", + "title": "Refine Proposals - Submitted", + "description": "Async task envelope returned when refine_proposals cannot produce revised proposals before the response — for example, when refining a complex multi-publisher proposal requires upstream pricing queries or manual re-underwriting. The buyer can poll get_task_status with task_id. If the originating request carried push_notification_config, the seller delivers at least the terminal completion or failure notification to that webhook URL; intermediate progress notifications are MAY. The results array lands on the completion artifact, not this envelope.", + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "submitted", + "description": "Task-level status literal. Discriminates this async envelope from the synchronous success shape, whose results array is issued in-line. See task-status.json for the full task-status enum." + }, + "task_id": { + "type": "string", + "description": "Task handle the buyer uses with tasks/get, and that the seller references on push-notification callbacks. The results array is issued on the completion artifact, not here.", + "x-entity": "task" + }, + "message": { + "type": "string", + "maxLength": 2000, + "description": "Optional human-readable explanation of why the task is submitted — e.g., 'Proposal revision queued; re-underwriting required for the requested budget change.' Plain text only. Buyers MUST treat this as untrusted seller input: escape before rendering to HTML UIs, and sanitize or isolate before passing to an LLM prompt context." + }, + "estimated_completion": { + "type": "string", + "format": "date-time", + "description": "Estimated completion time for proposal refinement." + }, + "errors": { + "type": "array", + "description": "Optional advisory errors accompanying the submitted envelope. Use only for non-blocking warnings. Terminal failures belong in the error branch, not here.", + "items": { + "$ref": "/schemas/core/error.json" + } + }, + "context": { + "$ref": "/schemas/core/context.json" + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": ["status", "task_id"], + "additionalProperties": true, + "not": { + "anyOf": [ + { "required": ["results"] }, + { "required": ["proposals"] }, + { "required": ["result"] } + ] + } +} diff --git a/static/schemas/source/media-buy/request-proposals-async-response-submitted.json b/static/schemas/source/media-buy/request-proposals-async-response-submitted.json new file mode 100644 index 0000000000..b41c6aa394 --- /dev/null +++ b/static/schemas/source/media-buy/request-proposals-async-response-submitted.json @@ -0,0 +1,51 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/request-proposals-async-response-submitted.json", + "title": "Request Proposals - Submitted", + "description": "Async task envelope returned when request_proposals cannot produce executable proposals before the response — for example, when consultative planning requires upstream system queries or human sales-desk review. The buyer can poll get_task_status with task_id. If the originating request carried push_notification_config, the seller delivers at least the terminal completion or failure notification to that webhook URL; intermediate progress notifications are MAY. The proposals array lands on the completion artifact, not this envelope.", + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "submitted", + "description": "Task-level status literal. Discriminates this async envelope from the synchronous success shape, whose proposals array is issued in-line. See task-status.json for the full task-status enum." + }, + "task_id": { + "type": "string", + "description": "Task handle the buyer uses with tasks/get, and that the seller references on push-notification callbacks. The proposals array is issued on the completion artifact, not here.", + "x-entity": "task" + }, + "message": { + "type": "string", + "maxLength": 2000, + "description": "Optional human-readable explanation of why the task is submitted — e.g., 'Proposal planning queued for sales-desk review; typical turnaround 1–4 hours.' Plain text only. Buyers MUST treat this as untrusted seller input: escape before rendering to HTML UIs, and sanitize or isolate before passing to an LLM prompt context." + }, + "estimated_completion": { + "type": "string", + "format": "date-time", + "description": "Estimated completion time for proposal generation." + }, + "errors": { + "type": "array", + "description": "Optional advisory errors accompanying the submitted envelope. Use only for non-blocking warnings. Terminal failures belong in the error branch, not here.", + "items": { + "$ref": "/schemas/core/error.json" + } + }, + "context": { + "$ref": "/schemas/core/context.json" + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": ["status", "task_id"], + "additionalProperties": true, + "not": { + "anyOf": [ + { "required": ["proposals"] }, + { "required": ["products"] }, + { "required": ["result"] } + ] + } +} From 5d6eac0e197a96c39134df8f4b66eae24221d87b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 12 Aug 2026 10:26:33 +0200 Subject: [PATCH 36/49] feat(media-buy): add compact 3.2 lifecycle --- .changeset/secure-get-products-idempotency.md | 2 +- docs.json | 3 + docs/accounts/tasks/sync_accounts.mdx | 3 +- .../cross-cutting/version-adaptation.mdx | 9 +- .../task-reference/sync_creatives.mdx | 7 +- docs/governance/campaign/specification.mdx | 7 +- .../campaign/tasks/check_governance.mdx | 5 +- docs/media-buy/media-buys/index.mdx | 2 +- docs/media-buy/specification.mdx | 8 +- .../task-reference/accept_proposal.mdx | 34 ++ .../media-buy/task-reference/buy_products.mdx | 38 +++ .../task-reference/control_media_buy.mdx | 43 +++ .../task-reference/create_media_buy.mdx | 4 + .../task-reference/decline_proposals.mdx | 3 +- .../task-reference/get_media_buys.mdx | 6 +- docs/media-buy/task-reference/index.mdx | 15 +- .../task-reference/list_products.mdx | 18 +- .../task-reference/refine_proposals.mdx | 26 +- .../task-reference/request_proposals.mdx | 3 +- .../task-reference/update_media_buy.mdx | 4 + docs/protocol/calling-an-agent.mdx | 2 + docs/protocol/get_adcp_capabilities.mdx | 8 +- docs/reference/release-notes.mdx | 14 +- docs/snippets/compliance-error-codes.mdx | 12 +- server/src/training-agent/idempotency.ts | 3 + server/src/training-agent/task-handlers.ts | 16 +- server/src/training-agent/tenants/router.ts | 2 +- .../unit/training-agent-idempotency.test.ts | 4 +- server/tests/unit/training-agent.test.ts | 18 +- specs/wholesale-feed-webhooks.md | 49 +-- .../source/account/sync-accounts-request.json | 2 +- .../comply-test-controller-request.json | 8 +- .../compliance/task-completion-data.json | 20 ++ .../source/core/account-authorization.json | 2 +- .../source/core/async-response-data.json | 25 ++ .../source/core/canonical-account-ref.json | 38 +++ ...canonical-audience-evidence-selection.json | 19 ++ .../core/canonical-audience-evidence.json | 53 +++ .../core/canonical-budget-allocation.json | 30 ++ .../core/canonical-delivery-forecast.json | 26 ++ .../source/core/canonical-forecast-point.json | 35 ++ ...anonical-forecast-vendor-metric-value.json | 17 + .../source/core/canonical-format-option.json | 48 +++ .../core/canonical-measurement-terms.json | 34 ++ .../core/canonical-media-buy-action.json | 54 +++ .../core/canonical-media-buy-features.json | 18 + .../core/canonical-metric-qualifier.json | 15 + .../core/canonical-optimization-goal.json | 98 ++++++ .../core/canonical-performance-standard.json | 15 + .../source/core/canonical-placement.json | 34 ++ .../source/core/canonical-pricing-option.json | 52 +++ .../source/core/canonical-product-action.json | 16 + .../source/core/canonical-product.json | 102 ++++-- .../source/core/canonical-proposal.json | 55 +++ .../canonical-reporting-capabilities.json | 38 +++ .../core/canonical-reporting-commitment.json | 33 ++ .../core/compact-task-input-required.json | 18 + .../source/core/compact-task-submitted.json | 20 ++ .../source/core/compact-task-working.json | 16 + .../schemas/source/core/keyword-target.json | 14 + .../core/media-buy-available-action.json | 5 + .../source/core/media-buy-features.json | 3 +- static/schemas/source/core/media-buy.json | 18 +- .../source/core/notification-config.json | 19 ++ .../schemas/source/core/planned-delivery.json | 22 ++ .../source/core/product-offer-filters.json | 6 +- static/schemas/source/core/proposal.json | 9 +- static/schemas/source/core/targeting.json | 2 +- .../source/core/wholesale-feed-event.json | 57 ++-- .../source/core/wholesale-feed-webhook.json | 41 ++- .../creative/sync-creatives-request.json | 122 ++++++- .../canonical-media-buy-action-mode.json | 13 + .../enums/canonical-media-buy-action.json | 34 ++ static/schemas/source/enums/error-code.json | 8 +- .../source/enums/media-buy-action-mode.json | 4 +- .../source/enums/notification-type.json | 4 +- .../schemas/source/enums/proposal-status.json | 10 +- static/schemas/source/enums/task-type.json | 6 + .../governance/check-governance-request.json | 26 +- static/schemas/source/index.json | 128 ++++++- ...roposal-async-response-input-required.json | 9 + ...ept-proposal-async-response-submitted.json | 9 + ...ccept-proposal-async-response-working.json | 9 + .../media-buy/accept-proposal-request.json | 109 ++++++ .../media-buy/accept-proposal-response.json | 9 + ...roducts-async-response-input-required.json | 9 + ...buy-products-async-response-submitted.json | 9 + .../buy-products-async-response-working.json | 9 + .../media-buy/buy-products-request.json | 152 +++++++++ .../media-buy/buy-products-response.json | 9 + .../source/media-buy/commercial-terms.json | 103 ++++++ ...dia-buy-async-response-input-required.json | 9 + ...ol-media-buy-async-response-submitted.json | 9 + ...trol-media-buy-async-response-working.json | 9 + .../media-buy/control-media-buy-request.json | 148 +++++++++ .../media-buy/control-media-buy-response.json | 69 ++++ .../media-buy/create-media-buy-request.json | 5 +- ...oposals-async-response-input-required.json | 9 + ...ne-proposals-async-response-submitted.json | 9 + ...line-proposals-async-response-working.json | 9 + .../media-buy/decline-proposals-response.json | 26 +- .../media-buy/get-media-buys-response.json | 57 +++- .../media-buy/list-products-request.json | 35 +- .../media-buy/list-products-response.json | 27 +- .../media-buy-commitment-response.json | 133 ++++++++ .../source/media-buy/package-control.json | 167 ++++++++++ .../source/media-buy/product-fields.json | 18 +- .../source/media-buy/product-purchase.json | 131 ++++++++ .../source/media-buy/proposal-refinement.json | 10 +- ...oposals-async-response-input-required.json | 9 + ...ne-proposals-async-response-submitted.json | 9 + ...fine-proposals-async-response-working.json | 9 + .../media-buy/refine-proposals-request.json | 2 +- .../media-buy/refine-proposals-response.json | 32 +- ...oposals-async-response-input-required.json | 9 + ...st-proposals-async-response-submitted.json | 9 + ...uest-proposals-async-response-working.json | 9 + .../media-buy/request-proposals-request.json | 29 +- .../media-buy/request-proposals-response.json | 33 +- .../media-buy/update-media-buy-request.json | 5 +- .../get-adcp-capabilities-response.json | 55 ++- tests/composed-schema-validation.test.cjs | 312 +++++++++++++++++- tests/docs-nav-validation.test.cjs | 3 + tests/mcp-schema-projection.test.cjs | 110 +++++- 124 files changed, 3568 insertions(+), 243 deletions(-) create mode 100644 docs/media-buy/task-reference/accept_proposal.mdx create mode 100644 docs/media-buy/task-reference/buy_products.mdx create mode 100644 docs/media-buy/task-reference/control_media_buy.mdx create mode 100644 static/schemas/source/compliance/task-completion-data.json create mode 100644 static/schemas/source/core/canonical-account-ref.json create mode 100644 static/schemas/source/core/canonical-audience-evidence-selection.json create mode 100644 static/schemas/source/core/canonical-audience-evidence.json create mode 100644 static/schemas/source/core/canonical-budget-allocation.json create mode 100644 static/schemas/source/core/canonical-delivery-forecast.json create mode 100644 static/schemas/source/core/canonical-forecast-point.json create mode 100644 static/schemas/source/core/canonical-forecast-vendor-metric-value.json create mode 100644 static/schemas/source/core/canonical-format-option.json create mode 100644 static/schemas/source/core/canonical-measurement-terms.json create mode 100644 static/schemas/source/core/canonical-media-buy-action.json create mode 100644 static/schemas/source/core/canonical-media-buy-features.json create mode 100644 static/schemas/source/core/canonical-metric-qualifier.json create mode 100644 static/schemas/source/core/canonical-optimization-goal.json create mode 100644 static/schemas/source/core/canonical-performance-standard.json create mode 100644 static/schemas/source/core/canonical-placement.json create mode 100644 static/schemas/source/core/canonical-pricing-option.json create mode 100644 static/schemas/source/core/canonical-product-action.json create mode 100644 static/schemas/source/core/canonical-proposal.json create mode 100644 static/schemas/source/core/canonical-reporting-capabilities.json create mode 100644 static/schemas/source/core/canonical-reporting-commitment.json create mode 100644 static/schemas/source/core/compact-task-input-required.json create mode 100644 static/schemas/source/core/compact-task-submitted.json create mode 100644 static/schemas/source/core/compact-task-working.json create mode 100644 static/schemas/source/core/keyword-target.json create mode 100644 static/schemas/source/enums/canonical-media-buy-action-mode.json create mode 100644 static/schemas/source/enums/canonical-media-buy-action.json create mode 100644 static/schemas/source/media-buy/accept-proposal-async-response-input-required.json create mode 100644 static/schemas/source/media-buy/accept-proposal-async-response-submitted.json create mode 100644 static/schemas/source/media-buy/accept-proposal-async-response-working.json create mode 100644 static/schemas/source/media-buy/accept-proposal-request.json create mode 100644 static/schemas/source/media-buy/accept-proposal-response.json create mode 100644 static/schemas/source/media-buy/buy-products-async-response-input-required.json create mode 100644 static/schemas/source/media-buy/buy-products-async-response-submitted.json create mode 100644 static/schemas/source/media-buy/buy-products-async-response-working.json create mode 100644 static/schemas/source/media-buy/buy-products-request.json create mode 100644 static/schemas/source/media-buy/buy-products-response.json create mode 100644 static/schemas/source/media-buy/commercial-terms.json create mode 100644 static/schemas/source/media-buy/control-media-buy-async-response-input-required.json create mode 100644 static/schemas/source/media-buy/control-media-buy-async-response-submitted.json create mode 100644 static/schemas/source/media-buy/control-media-buy-async-response-working.json create mode 100644 static/schemas/source/media-buy/control-media-buy-request.json create mode 100644 static/schemas/source/media-buy/control-media-buy-response.json create mode 100644 static/schemas/source/media-buy/decline-proposals-async-response-input-required.json create mode 100644 static/schemas/source/media-buy/decline-proposals-async-response-submitted.json create mode 100644 static/schemas/source/media-buy/decline-proposals-async-response-working.json create mode 100644 static/schemas/source/media-buy/media-buy-commitment-response.json create mode 100644 static/schemas/source/media-buy/package-control.json create mode 100644 static/schemas/source/media-buy/product-purchase.json create mode 100644 static/schemas/source/media-buy/refine-proposals-async-response-input-required.json create mode 100644 static/schemas/source/media-buy/refine-proposals-async-response-submitted.json create mode 100644 static/schemas/source/media-buy/refine-proposals-async-response-working.json create mode 100644 static/schemas/source/media-buy/request-proposals-async-response-input-required.json create mode 100644 static/schemas/source/media-buy/request-proposals-async-response-submitted.json create mode 100644 static/schemas/source/media-buy/request-proposals-async-response-working.json diff --git a/.changeset/secure-get-products-idempotency.md b/.changeset/secure-get-products-idempotency.md index dcebd627c0..3ea6ef89e7 100644 --- a/.changeset/secure-get-products-idempotency.md +++ b/.changeset/secure-get-products-idempotency.md @@ -2,4 +2,4 @@ "adcontextprotocol": minor --- -Add the compact AdCP 3.2 product lifecycle: `list_products`, `request_proposals`, `refine_proposals`, and `decline_proposals`. The task-specific contracts separate offer discovery from executable proposal creation, immutable refinement, terminal decline, and purchase while retaining `get_products` as a key-optional compatibility facade throughout 3.x. A shared opportunity reference connects planning-cycle context without duplicating proposal version identity. Product-discovery inputs use stable brand keys and an offer-only filter graph, preventing buyer calls from transitively embedding brand assets or content provenance. Split-tool product responses are canonical-only: they require `format_options` and reject legacy product or placement `format_ids` and `v1_format_ref` compatibility links. Publish machine-readable SDK fallback grades and task-result schema resolution, plus an MCP production profile that filters out compliance-only and deprecated tools and removes presentation annotations without changing validation semantics. +Add the compact AdCP 3.2 product and MediaBuy lifecycle: `list_products`, `request_proposals`, `refine_proposals`, `decline_proposals`, `buy_products`, `accept_proposal`, and `control_media_buy`. The task-specific contracts separate offer discovery, executable proposal creation, immutable refinement, terminal decline, direct purchase, proposal acceptance, and operational delivery control while retaining `get_products`, `create_media_buy`, and `update_media_buy` as compatibility facades throughout 3.x. New purchase and control inputs never accept inline creatives; commercial amendments and negotiated cancellations fork an accepted proposal, while operational controls remain revision-checked. Compact purchase snapshots preserve resolved package flight, billing-measurement, performance, and reporting terms. A shared opportunity reference connects planning-cycle context through purchase without duplicating proposal version identity. Canonical inputs use stable brand keys, catalog references, and compact account and optimization types so brand assets, legacy named formats, creative provenance, and compliance payloads do not transitively enter the clean tools. Publish machine-readable SDK fallback grades and task-result schema resolution, plus an MCP production profile that filters out compliance-only and deprecated tools and removes presentation annotations without changing validation semantics. diff --git a/docs.json b/docs.json index db19c08561..c2cad1270d 100644 --- a/docs.json +++ b/docs.json @@ -298,6 +298,9 @@ "docs/media-buy/task-reference/request_proposals", "docs/media-buy/task-reference/refine_proposals", "docs/media-buy/task-reference/decline_proposals", + "docs/media-buy/task-reference/buy_products", + "docs/media-buy/task-reference/accept_proposal", + "docs/media-buy/task-reference/control_media_buy", "docs/media-buy/task-reference/create_media_buy", "docs/media-buy/task-reference/sync_catalogs", "docs/media-buy/task-reference/get_media_buys", diff --git a/docs/accounts/tasks/sync_accounts.mdx b/docs/accounts/tasks/sync_accounts.mdx index c1c12ec598..08a51f407e 100644 --- a/docs/accounts/tasks/sync_accounts.mdx +++ b/docs/accounts/tasks/sync_accounts.mdx @@ -201,6 +201,7 @@ Each entry has: - `subscriber_id` — buyer-supplied identifier, unique within the account; echoed on every fire so multi-subscriber accounts can route by endpoint - `url` — HTTPS endpoint URL. Sellers MUST complete an endpoint activation challenge or equivalent proof-of-control before treating a new or changed active subscriber as active. - `event_types[]` — types the subscriber wants. Only account-anchored types are permitted (today: `creative.status_changed`, `creative.purged`, `account.status_changed`, `product.created`, `product.updated`, `product.priced`, `product.removed`, `signal.created`, `signal.updated`, `signal.priced`, `signal.removed`, `wholesale_feed.bulk_change`). Sellers MUST reject any media-buy-anchored type (`scheduled`, `final`, `delayed`, `adjusted`, `window_update`, `impairment`) or agent-anchored type (`capabilities.changed`) as a per-account validation failure with [`INVALID_REQUEST`](/docs/building/verification/compliance-catalog#error-code-invalid-request) or [`VALIDATION_ERROR`](/docs/building/verification/compliance-catalog#error-code-validation-error) in `accounts[].errors[]`, and `error.field` MUST point at the invalid `event_types` entry. +- `product_payload_view` — `canonical` for a [`list_products`](/docs/media-buy/task-reference/list_products) mirror or `legacy` for the 3.x `get_products` shape. Omission defaults to `legacy`; valid only when a product event is selected. - `authentication` (optional) — legacy Bearer or HMAC-SHA256. Omit to use the default RFC 9421 webhook profile. When present, the same signed-registration downgrade-resistance rules as `push_notification_config.authentication` apply. Credentials are write-only — sellers omit them on reads. - `active` (default `true`) — set `false` to pause a subscriber without removing the registration. Sellers MAY skip only the outbound proof challenge while `active: false`; they MUST still enforce HTTPS parsing, hostname normalization, and reserved-range rejection on write. Paused subscribers MUST NOT receive fires until reactivated. Reactivation MUST repeat full SSRF validation with connect pinning plus proof-of-control for any tuple without current valid proof. @@ -350,7 +351,7 @@ Governance agents registered via [`sync_governance`](/docs/accounts/tasks/sync_g Verify applied state via [`list_accounts`](/docs/accounts/tasks/list_accounts) — the response carries the current persisted `notification_configs[]` per account with credentials redacted. `sync_accounts` also echoes the current sanitized set on `created`, `updated`, and `unchanged` results when the request included `notification_configs` or any persisted subscribers already exist. -Wholesale feed notifications are registered here, not through a separate subscription task. The webhook body is [`wholesale-feed-webhook.json`](https://adcontextprotocol.org/schemas/v3/core/wholesale-feed-webhook.json): it carries the changed product, signal, or bulk-change summary plus the post-change `wholesale_feed_version`. Sellers MUST apply the same per-subscriber authorization and scope predicate used by the corresponding wholesale read before emitting each webhook. Receivers MAY apply the payload to local mirrors; use `get_products` / `get_signals` with `if_wholesale_feed_version` to repair missed or distrusted pushes and before binding spend or authority. See [wholesale_feed_webhooks](/docs/protocol/get_adcp_capabilities#wholesale_feed_webhooks) for capability declaration and event semantics. +Wholesale feed notifications are registered here, not through a separate subscription task. The webhook body is [`wholesale-feed-webhook.json`](https://adcontextprotocol.org/schemas/v3/core/wholesale-feed-webhook.json): it carries a canonical product replacement for `list_products` subscribers (or the legacy Product view for `get_products` subscribers), signal changes, or a bulk-change summary plus the post-change `wholesale_feed_version`. Sellers MUST apply the same per-subscriber authorization and scope predicate used by the corresponding wholesale read before emitting each webhook. For a detected gap, repair with the mirror's last applied version; for uncertain or bulk state, omit the conditional token and replace from `list_products` / `get_signals`. See [wholesale_feed_webhooks](/docs/protocol/get_adcp_capabilities#wholesale_feed_webhooks) for capability declaration and event semantics. ## Common scenarios diff --git a/docs/building/cross-cutting/version-adaptation.mdx b/docs/building/cross-cutting/version-adaptation.mdx index a75d2a30b2..6115d12e92 100644 --- a/docs/building/cross-cutting/version-adaptation.mdx +++ b/docs/building/cross-cutting/version-adaptation.mdx @@ -62,7 +62,7 @@ When AdCP 3.1 ships and you bump the SDK, a new adapter folder appears for the n ### Same-major tool replacements -AdCP 3.2's product lifecycle also replaces one broad 3.x facade with narrower tool names. This is not ordinary version-shape adaptation: some current operations need more than one legacy call, and one has no faithful legacy equivalent. The release manifest makes that distinction machine-readable through `legacy_fallback` on each replacement and `superseded_by` on `get_products`. +AdCP 3.2's compact lifecycle replaces the broad `get_products`, `create_media_buy`, and `update_media_buy` facades with narrower tool names. This is not ordinary version-shape adaptation: some current operations need more than one legacy call, and one has no faithful legacy equivalent. The release manifest makes that distinction machine-readable through `legacy_fallback` on each replacement and `superseded_by` on each compatibility facade. | Current SDK method | Legacy peer tool | Fallback mode | Required behavior | |---|---|---|---| @@ -70,10 +70,13 @@ AdCP 3.2's product lifecycle also replaces one broad 3.x facade with narrower to | `requestProposals` | `get_products` brief, then finalize when needed | `orchestrated` | Return only executable committed snapshots; resume the same sequence safely on retry | | `refineProposals` | `get_products` refine, then finalize when needed | `orchestrated` | Mint and return executable immutable revisions with source-result correspondence | | `declineProposals` | none | `none` | Throw a typed capability error before sending a call; never silently discard terminal feedback | +| `buyProducts` | `create_media_buy` explicit packages | `orchestrated` | Translate product purchases to packages without adding creatives, then materialize the accepted proposal snapshot expected by the current result | +| `acceptProposal` | `create_media_buy` proposal mode | `orchestrated` | Execute a new-buy proposal directly; amendment/cancellation proposals require an SDK-managed compatibility workflow or a typed capability error when the legacy seller cannot preserve it | +| `controlMediaBuy` | `update_media_buy` | `orchestrated` | Map only canonical operational fields and never route creative, flight, package-addition, or billing-term mutations through this adapter | -Client SDKs SHOULD expose the current methods as their primary API and choose the peer path from `media_buy.product_discovery_tools`. A directly advertised current tool always wins. A `direct` fallback may run transparently. An `orchestrated` fallback may run transparently only when the SDK ships a handwritten adapter that preserves the current tool's result completeness, terminal state, atomicity, and idempotency semantics; the manifest classification does not synthesize that state machine. Each underlying legacy mutation needs its own deterministic replay identity, and retrying the current SDK call must resume rather than restart the sequence. Otherwise the SDK MUST throw a typed `CapabilityUnsupportedError` (or language equivalent) carrying `requested_tool`, the peer's advertised tools, and `fallback_mode`. A `none` fallback MUST throw that error before sending a semantically different legacy call. +Client SDKs SHOULD expose the current methods as their primary API and choose the peer path from `media_buy.lifecycle_tools`. A directly advertised current tool always wins. A `direct` fallback may run transparently. An `orchestrated` fallback may run transparently only when the SDK ships a handwritten adapter that preserves the current tool's result completeness, terminal state, atomicity, and idempotency semantics; the manifest classification does not synthesize that state machine. In particular, SDKs adapting a direct 3.x creation MUST retain the accepted commercial snapshot locally when the legacy seller cannot return it, and MUST not pretend a legacy seller supports proposal-based amendments. Each underlying legacy mutation needs its own deterministic replay identity, and retrying the current SDK call must resume rather than restart the sequence. Otherwise the SDK MUST throw a typed `CapabilityUnsupportedError` carrying `requested_tool`, the peer's advertised tools, and `fallback_mode`. -These tools deliberately retain distinct `operation_family` values. Fallback metadata describes SDK adaptation, not cross-name authorization, idempotency equivalence, task recovery, or webhook identity. A client adapter MUST be authorized for the actual legacy `get_products` call; a grant for a split tool does not transfer across names. Server SDKs may use the inverse pattern—implement the current lifecycle once and derive the deprecated `get_products` facade for 3.x callers—but may advertise only behavior the adapter actually preserves. +These tools deliberately retain distinct `operation_family` values. Fallback metadata describes SDK adaptation, not cross-name authorization, idempotency equivalence, task recovery, or webhook identity. A client adapter MUST be authorized for the actual legacy call; a grant for a split tool does not transfer across names. Server SDKs may implement the current lifecycle once and derive the deprecated facades for 3.x callers, but may advertise only behavior the adapter actually preserves. ## Mechanism 2 — Migrate SDK majors via co-existence diff --git a/docs/creative/task-reference/sync_creatives.mdx b/docs/creative/task-reference/sync_creatives.mdx index bb22abc1c9..5f82c884c8 100644 --- a/docs/creative/task-reference/sync_creatives.mdx +++ b/docs/creative/task-reference/sync_creatives.mdx @@ -904,7 +904,8 @@ Poll `tasks/get` or wait for the webhook. The completion artifact carries the `c ### Upsert (default) - Creates new creatives or updates existing by `creative_id` -- Merges package assignments (additive) +- `assignments[]` remains the deprecated additive assignment shorthand +- `assignment_operations[]` can traffic existing IDs without resending creative bodies: `assign` upserts weight/placement scope, `unassign` removes one assignment, and `replace` atomically swaps an existing creative for a replacement - Updates provided fields, leaves others unchanged - Use `creative_ids` filter to limit scope to specific creatives @@ -932,13 +933,13 @@ Poll `tasks/get` or wait for the webhook. The completion artifact carries the `c 2. **Rehearse seller acceptance first** - Use `dry_run: true` when you need to catch upload, upsert, assignment, account, policy, or format errors before mutating the seller's creative library. Use [`validate_input`](https://adcontextprotocol.org/schemas/v3/creative/validate-input-request.json) earlier in the workflow only for manifest-structure preflight or multi-target product comparison. -3. **Batch assignments** - Include all package assignments in single sync call to avoid race conditions between updates. +3. **Batch assignment operations** - Use one idempotent `assignment_operations[]` call for assignment updates, removals, and replacements. This keeps creative trafficking separate from MediaBuy commercial controls and avoids races between calls. 4. **CDN-hosted assets** - Use publicly accessible CDN URLs for faster processing. Platforms can fetch assets directly without proxy delays. 5. **Brand identity** - For generative creatives, validate brand identity schema before syncing to avoid processing failures. -6. **Active delivery protection** - Creatives assigned to active, non-paused packages cannot be updated or deleted via `delete_missing`. Pause the package first, unassign the creative via [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy), or create a new creative with a different `creative_id`. +6. **Active delivery protection** - Creatives assigned to active, non-paused packages cannot be updated or deleted via `delete_missing`. Pause the package first, use `assignment_operations` to unassign or replace the creative, or create a new creative with a different `creative_id`. ## Related tasks diff --git a/docs/governance/campaign/specification.mdx b/docs/governance/campaign/specification.mdx index 9a11e07dfd..932d483d45 100644 --- a/docs/governance/campaign/specification.mdx +++ b/docs/governance/campaign/specification.mdx @@ -144,6 +144,8 @@ Request schemas use `x-governed-commitment` to declare whether every invocation For conditional tasks, services evaluate the **effective state delta**, not the presence of one request field in isolation. `trigger_overrides_exemption` is the mandatory precedence rule: an exemption applies only when the entire effective delta is risk-reducing or cost-free. If any part of a mixed request creates a trigger, the whole atomic request requires governance. Evaluation is task-specific: - `update_media_buy`: load the current buy at the requested revision and compare the atomic result. Any higher committed budget, later end, resume, new package, or movement into materially different delivery is a trigger. Cancel, pause, or a pure decrease is exempt only when no triggering change is bundled. +- `accept_proposal`: accepting a new-buy proposal or a commitment-increasing amendment triggers governance. A negotiated cancellation or decrease-only amendment is exempt when it contains no triggering change, so cleanup remains available during an outage or denial. +- `control_media_buy`: compare the requested operational patch with the accepted proposal and authoritative current revision. A higher committed budget, resume, or delivery reallocation triggers governance. Cancel, pause, and decrease-only controls are exempt only when no triggering change is bundled; commercial changes outside the accepted envelope return `REQUOTE_REQUIRED` and proceed through proposal refinement. - `update_rights`: load the current grant. Any higher price or cap, later end, resume, or pricing-option change is a trigger. Pause or a pure decrease is exempt only when no trigger is bundled. - `activate_signal`: `activate` triggers; `deactivate` is exempt. - `build_creative`: resolve the selected pricing option before execution. A positive vendor charge triggers; an estimate-only call or retrieval with zero vendor charge is exempt. If the service cannot establish zero cost before execution, it treats the request as triggering. @@ -153,14 +155,17 @@ The strings in `triggers` and `exemptions` are stable audit labels, not JSON pat The invocation requirement covers: - [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) — committed budget across packages +- [`buy_products`](/docs/media-buy/task-reference/buy_products) — committed budget across canonical product purchases - [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy) — increases, extensions, resumptions, and reallocations; cancellation, pause, and decrease-only updates are exempt +- [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal) — new commitments and commitment-increasing amendments; negotiated cancellations and decrease-only amendments are exempt +- [`control_media_buy`](/docs/media-buy/task-reference/control_media_buy) — commitment-increasing or reallocating controls; cancellation, pause, and decrease-only controls are exempt - [`acquire_rights`](/docs/brand-protocol/tasks/acquire_rights) — rights pricing - [`update_rights`](/docs/brand-protocol/tasks/update_rights) — increases, extensions, resumptions, and pricing changes; pause and decrease-only updates are exempt - [`activate_signal`](/docs/signals/tasks/activate_signal) — activation only; deactivation is exempt - [`build_creative`](/docs/creative/task-reference/build_creative) — executions that incur vendor cost; estimates and zero-cost retrieval are exempt - Any future spend-commit task -When a task payload contains its numeric commitment directly, the governance agent evaluates that field. `update_media_buy` is the important exception: the buyer computes the positive delta from its last confirmed revision and sends it as `proposed_commitment`; the governance agent treats that declaration as a ceiling and does not need access to seller-internal buy state. When a task identifies price only indirectly (for example a rights or signal `pricing_option_id`), the intent check likewise MUST include task-neutral `proposed_commitment: {amount, currency}`. Amount `0` is an explicit assertion that the resolved action is no-cost, not an omission. The intent separately names the exact downstream `target_agent`; that role-neutral routing field becomes the token audience and is not injected into the business payload. An approval signs the ceiling into `governance_context.authorized_commitment` and binds the exact task and business request with critical `authorized_task` and `authorized_payload_hash` claims. The payload hash is JCS/SHA-256 over the downstream task request excluding exactly the top-level governance/transport fields `governance_context` and `context`; `idempotency_key` and every business field remain bound. The downstream service recomputes that hash, compares its independently computed price or positive update delta and currency with the signed ceiling, and rejects any task, payload, amount, or currency mismatch. A governance agent that can resolve a pricing identifier itself MUST also reject a conflicting declaration. The approved ceiling is retained for outcome reconciliation; a lower report after execution cannot restore headroom. +When a task payload contains its numeric commitment directly, the governance agent evaluates that field. `update_media_buy` and `control_media_buy` are the important exceptions: the buyer computes the positive delta from its last confirmed revision and sends it as `proposed_commitment`; the governance agent treats that declaration as a ceiling and does not need access to seller-internal buy state. When a task identifies price only indirectly (for example a rights or signal `pricing_option_id`), the intent check likewise MUST include task-neutral `proposed_commitment: {amount, currency}`. Amount `0` is an explicit assertion that the resolved action is no-cost, not an omission. The intent separately names the exact downstream `target_agent`; that role-neutral routing field becomes the token audience and is not injected into the business payload. An approval signs the ceiling into `governance_context.authorized_commitment` and binds the exact task and business request with critical `authorized_task` and `authorized_payload_hash` claims. The payload hash is JCS/SHA-256 over the downstream task request excluding exactly the top-level governance/transport fields `governance_context` and `context`; `idempotency_key` and every business field remain bound. The downstream service recomputes that hash, compares its independently computed price or positive update delta and currency with the signed ceiling, and rejects any task, payload, amount, or currency mismatch. A governance agent that can resolve a pricing identifier itself MUST also reject a conflicting declaration. The approved ceiling is retained for outcome reconciliation; a lower report after execution cannot restore headroom. Invocation is NOT required for discovery tasks, reporting tasks, operational-status tasks, or the explicit risk-reducing exemptions above. The MUST fires specifically when an operation creates, increases, extends, reallocates, or resumes an obligation. diff --git a/docs/governance/campaign/tasks/check_governance.mdx b/docs/governance/campaign/tasks/check_governance.mdx index 7f540906a1..e2e07dbba1 100644 --- a/docs/governance/campaign/tasks/check_governance.mdx +++ b/docs/governance/campaign/tasks/check_governance.mdx @@ -527,10 +527,11 @@ The seller MUST pause delivery immediately and correct the geo targeting before | `caller` | string (URI) | Yes | Claimed agent URL. The transport credential must resolve to the same URL, which is used for authorization and audit. Intent callers must be the authenticated plan owner or hold an active delegation; execution callers must match the preserved target audience. | | `purchase_type` | enum | No | The kind of financial commitment being validated: `media_buy` (default), `rights_license`, `signal_activation`, or `creative_services`. When omitted, the governance agent assumes `media_buy`. | | `target_agent` | string (URI) | Intent | Exact downstream service URL. Required on intent checks and signed as the token audience. It is routing metadata and is not included in `payload`. | -| `proposed_commitment` | object | Conditional | Intent-only task-neutral `{amount, currency}`. Required for [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy), [`acquire_rights`](/docs/brand-protocol/tasks/acquire_rights), commitment-increasing [`update_rights`](/docs/brand-protocol/tasks/update_rights), [`activate_signal`](/docs/signals/tasks/activate_signal), and [`build_creative`](/docs/creative/task-reference/build_creative). For `update_media_buy`, the buyer declares the positive delta from its last confirmed revision; for indirectly priced tasks it declares the resolved price. Use amount `0` for a verified no-cost or non-increasing action. This becomes the signed intent ceiling and governance-owned ledger authority. | +| `proposed_commitment` | object | Conditional | Intent-only task-neutral `{amount, currency}`. Required for [`buy_products`](/docs/media-buy/task-reference/buy_products), [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal), [`control_media_buy`](/docs/media-buy/task-reference/control_media_buy), [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy), [`acquire_rights`](/docs/brand-protocol/tasks/acquire_rights), commitment-increasing [`update_rights`](/docs/brand-protocol/tasks/update_rights), [`activate_signal`](/docs/signals/tasks/activate_signal), and [`build_creative`](/docs/creative/task-reference/build_creative). For update/control it is the positive delta; for `accept_proposal` it derives from `proposal.commercial_terms`; for `buy_products` it derives from the purchase payload. Use amount `0` for a verified no-cost or non-increasing action. | | `execution_commitment` | object | Update execution | Seller-computed `{amount, currency}` positive delta for `update_media_buy`. The seller derives it from authoritative current state atomically with the revision check and write. It must not exceed or change the currency of `proposed_commitment`; a mismatch requires a fresh intent. | -| `tool` | string | Intent | The AdCP tool being checked. Present on intent checks (orchestrator). The governance agent uses the presence of `tool` + `payload` to identify an intent check. | +| `tool` | string | Intent | The AdCP tool being checked, including compact `buy_products`, `accept_proposal`, and `control_media_buy`. Present on intent checks; `tool` + `payload` identifies an intent check. | | `payload` | object | Intent | Full downstream tool arguments exactly as they will be sent to `target_agent`. Present on intent checks. | +| `proposal` | CanonicalProposal | `accept_proposal` | Exact committed proposal being authorized. The governance agent verifies `terms_digest` over `commercial_terms` and binds that digest into its decision; the downstream payload carries the same digest. | | `governance_context` | string | Execution | Opaque context from a prior approved response. Required with `planned_delivery` or `delivery_metrics`; the governance agent derives the plan from it. Services persist and forward it without business-level parsing. | | `consultation_context` | string | Intent re-check | Non-authorizing handle from a conditions response. Return only with the adjusted intent and the same authenticated principal, caller, plan, tool, purchase type, and target audience; never forward it to a service. | | `phase` | enum | Execution | `purchase`, `modification`, or `delivery`. Defaults to `purchase`. Present on execution checks. | diff --git a/docs/media-buy/media-buys/index.mdx b/docs/media-buy/media-buys/index.mdx index 9dbff78fab..106f385f98 100644 --- a/docs/media-buy/media-buys/index.mdx +++ b/docs/media-buy/media-buys/index.mdx @@ -398,7 +398,7 @@ Cancel a single package within an active media buy: - Cancellation is **irreversible** — canceled media buys and packages cannot be reactivated - Sellers MAY reject cancellation with error code `NOT_CANCELLABLE` (e.g., contractual obligations, in-production print orders) - A canceled package does not affect other packages in the same media buy. When all packages are canceled, sellers that support `add_packages` allow the buyer to add new packages via `new_packages` in `update_media_buy`. Otherwise, the buyer SHOULD explicitly cancel the media buy. -- Sellers MAY cancel media buys or packages (e.g., policy violation, inventory withdrawal). Seller-initiated cancellations set `cancellation.canceled_by: "seller"` and MUST trigger a webhook notification to the orchestrator. +- Sellers MAY cancel media buys or packages (e.g., policy violation, inventory withdrawal). Seller-initiated cancellations set `cancellation.canceled_by: "seller"` and MUST be recoverable through `get_media_buys`. AdCP 3.x does not define a durable resource-scoped status webhook; sellers may expose an implementation extension until the 4.0 notification contract lands. ### Package Lifecycle diff --git a/docs/media-buy/specification.mdx b/docs/media-buy/specification.mdx index b2aa5c6d0c..442c49c9b7 100644 --- a/docs/media-buy/specification.mdx +++ b/docs/media-buy/specification.mdx @@ -146,18 +146,18 @@ Any non-terminal ──── update(canceled: true) ──▶ canceled (termina - `update_media_buy` with `paused: false` resumes a buy that is currently `paused` or clears a latent create-time hold while the visible state is still `pending_creatives` or `pending_start`. Clearing a latent hold does not bypass setup blockers; the visible status remains pending until creatives are supplied and the flight can start. - `active` or `paused` → `completed` when the flight ends, goal is met, or budget is exhausted (seller-initiated) - Buyer-initiated cancellation uses `update_media_buy` with `canceled: true` and optional `cancellation_reason` -- Seller-initiated cancellation (e.g., policy violation, inventory withdrawal) transitions the media buy to `canceled` with `cancellation.canceled_by: "seller"`. Sellers MUST notify orchestrators via webhook when performing seller-initiated cancellation. -- Seller-initiated rejection (from `pending_creatives` or `pending_start`) MUST also notify orchestrators via `push_notification_config`. The webhook payload MUST include `media_buy_id`, `status: "rejected"`, and `rejection_reason`. +- Seller-initiated cancellation (e.g., policy violation, inventory withdrawal) transitions the media buy to `canceled` with `cancellation.canceled_by: "seller"`; buyers recover the state through `get_media_buys`. A durable resource-scoped status webhook is reserved for the 4.0 notification model—operation-scoped `push_notification_config` is not a future lifecycle subscription. +- Seller-initiated rejection (from `pending_creatives` or `pending_start`) is likewise observable through `get_media_buys`; implementations MUST NOT synthesize an operation-completion webhook after the originating operation has already completed. - Sales agents MUST include a `cancellation` object with `canceled_at` and `canceled_by` when transitioning a media buy or package to `canceled` - Sales agents MAY reject buyer cancellation of a non-terminal media buy with error code `NOT_CANCELLABLE` (e.g., when the seller contractually refuses mid-flight cancellation) - When a buyer attempts to cancel a media buy already in `canceled` (`canceled: true` on a `canceled` buy), sales agents MUST reject with `NOT_CANCELLABLE` - All other updates to media buys in terminal states (`completed`, `rejected`, `canceled`) — including `canceled: true` attempts against `completed` or `rejected` buys — MUST be rejected with `INVALID_STATE` - Rejection (`rejected` status) is only valid from `pending_creatives` or `pending_start`. Sales agents MUST NOT reject media buys that have already transitioned to `active`. -- Seller-initiated cancellation notifications MUST use the `push_notification_config` webhook provided by the orchestrator during `create_media_buy` or `update_media_buy`. The webhook payload MUST include `media_buy_id`, `status: "canceled"`, and a `cancellation` object with `canceled_at`, `canceled_by: "seller"`, and `reason`. +- The current 3.x wire does not define a durable seller-initiated MediaBuy status subscription. Sellers MUST preserve the transition in MediaBuy readback and history; buyers that need immediate push delivery should negotiate an implementation extension until the 4.0 resource-scoped notification contract lands. - The `canceled` field on update requests uses `"const": true` — only `true` is valid. Sending `canceled: false` fails schema validation. Cancellation is irreversible; there is no "uncancel" operation. - **Creative assignments are released on buy rejection or cancellation.** When a media buy transitions to `rejected` or `canceled`, all package-creative assignments on that media buy are released. For sellers that advertise `creative.has_creative_library: true`, the creatives persist in the creative library per [assignment state and creative state](/docs/creative/creative-libraries#creative-state-and-assignment-state-are-separate) and MAY be referenced by `creative_id` in a subsequent `create_media_buy` or `sync_creatives` call. Inline-only sellers that advertise `inline_creative_management` without a creative library MAY keep submitted creatives package-scoped; they do not advertise cross-buy reuse or `list_creatives` readback. - **Creative review is independent of buy outcome.** Sales agents MUST NOT implicitly reject a creative because its containing buy was rejected; a creative rejection MUST be a deliberate review decision with its own `rejection_reason`. If the buy was rejected because a creative violated content policy, the sales agent MAY reject that creative — but only via the normal review path with its own `rejection_reason`; the buy's `rejected` status is not itself sufficient. -- **Observability of released assignments.** The media-buy-level `canceled` or `rejected` transition (surfaced in the buy's `history` and in the webhook notifications required above) IS the audit record for all its released assignments; buyers MUST NOT rely on per-assignment diff observability. Released assignments no longer appear in `get_media_buys` responses for that buy. Buyers confirming library reusability SHOULD call `list_creatives` to verify the creative is still in the library and observe its current status. Inline-only buyers should retain their submitted creative bodies because `get_media_buys` surfaces package-level approval state, not full creative-body retrieval. Package-scoped deadlines (`creative_deadline`) on the prior package have no bearing on a library creative's eligibility for a new buy. +- **Observability of released assignments.** The media-buy-level `canceled` or `rejected` transition, preserved in `get_media_buys` status and history, IS the audit record for all its released assignments; buyers MUST NOT rely on per-assignment diff observability. A negotiated implementation webhook extension may accelerate discovery but is not required by 3.x. Released assignments no longer appear in `get_media_buys` responses for that buy. Buyers confirming library reusability SHOULD call `list_creatives` to verify the creative is still in the library and observe its current status. Inline-only buyers should retain their submitted creative bodies because `get_media_buys` surfaces package-level approval state, not full creative-body retrieval. Package-scoped deadlines (`creative_deadline`) on the prior package have no bearing on a library creative's eligibility for a new buy. - **Retention.** Sales agents with creative libraries SHOULD retain released creatives in the library for at least 90 days after the last assignment is released. A normative retention floor is specified in the creative retention contract tracked under [#2260](https://github.com/adcontextprotocol/adcp/issues/2260); until that contract lands, buyers relying on long-horizon reuse SHOULD verify persistence via `list_creatives` before referencing a released creative on a new buy. #### Revision and confirmation semantics diff --git a/docs/media-buy/task-reference/accept_proposal.mdx b/docs/media-buy/task-reference/accept_proposal.mdx new file mode 100644 index 0000000000..ac220accb8 --- /dev/null +++ b/docs/media-buy/task-reference/accept_proposal.mdx @@ -0,0 +1,34 @@ +--- +title: accept_proposal +description: "Accept a committed proposal to create, amend, or cancel a MediaBuy." +"og:title": "AdCP accept_proposal Task" +testable: true +--- + +`accept_proposal` is the single terminal action for the compact proposal lifecycle. It accepts exactly one committed immutable proposal snapshot. + +Depending on `proposal_kind`, acceptance: + +- creates a new MediaBuy; +- applies an amendment to an existing MediaBuy; or +- applies a negotiated cancellation. + +The proposal already carries the commercial terms, so the request does not repeat packages, dates, targeting, or creatives. + +{/* Using latest because accept_proposal is not yet released. Update after the 3.2 release. */} +**Request schema:** [`/schemas/latest/media-buy/accept-proposal-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/accept-proposal-request.json) + +```json +{ + "idempotency_key": "550e8400-e29b-41d4-a716-446655441011", + "account": { "account_id": "account_123" }, + "proposal_id": "proposal_committed_456", + "proposal_terms_digest": "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +} +``` + +For scalable proposals, include `total_budget`. If the proposal carries a signature-required insertion order, include `io_acceptance`. + +If the proposal belongs to an `opportunity`, acceptance closes that planning cycle with `accepted_with_seller`; callers may repeat the matching opportunity ID explicitly, while the accepted proposal retains the association. + +Success returns the resulting MediaBuy identity, the proposal with `proposal_status: "accepted"`, and `purchase_bindings[]` mapping proposal purchase positions to package IDs for later creative assignment. Acceptance is idempotent. The seller atomically verifies `proposal_id`, `proposal_terms_digest`, expiry, and any `base_media_buy_revision`; stale or mismatched acceptance never applies different terms. diff --git a/docs/media-buy/task-reference/buy_products.mdx b/docs/media-buy/task-reference/buy_products.mdx new file mode 100644 index 0000000000..5d31a3ec51 --- /dev/null +++ b/docs/media-buy/task-reference/buy_products.mdx @@ -0,0 +1,38 @@ +--- +title: buy_products +description: "Buy published product offers directly without proposals or inline creatives." +"og:title": "AdCP buy_products Task" +testable: true +--- + +`buy_products` is the buyer-composed purchase path. It creates a MediaBuy from canonical product selections returned by [`list_products`](/docs/media-buy/task-reference/list_products), accepting the published commercial terms as-is. + +Creative objects and assignments are deliberately absent. Use the creative lifecycle after the MediaBuy has been created. If the buyer needs different commercial terms, request or refine a proposal instead. + +Per-purchase flight dates, `measurement_terms`, and `performance_standards` may be omitted to inherit the published offer. The accepted snapshot resolves and preserves those terms. If this purchase ends a tracked planning cycle, `opportunity` closes it with `accepted_with_seller`. + +{/* Using latest because buy_products is not yet released. Update after the 3.2 release. */} +**Request schema:** [`/schemas/latest/media-buy/buy-products-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/buy-products-request.json) + +```json +{ + "idempotency_key": "550e8400-e29b-41d4-a716-446655441010", + "account": { "account_id": "account_123" }, + "brand": { "domain": "acmeoutdoor.example" }, + "feed_version": "products-2027-06-01T12:00:00Z", + "pricing_version": "pricing-42", + "purchases": [ + { + "product_id": "product_display_standard", + "pricing_option_id": "cpm_usd", + "budget": 50000 + } + ], + "start_time": "asap", + "end_time": "2027-07-01T00:00:00Z" +} +``` + +Success returns the MediaBuy identity and an `accepted_proposal` snapshot. Its typed `commercial_terms` preserve the exact feed and pricing versions, product and format selections, catalogs, budgets, targeting, flight, billing, and reporting commitments; `terms_digest` binds that envelope for governance and audit. The separate `purchase_bindings[]` maps each zero-based purchase position to its seller-assigned `package_id`, which SDKs use for later creative assignment without placing execution IDs inside the immutable digest. The seller creates this snapshot even though the buyer did not negotiate first, giving every MediaBuy the same commercial history for later [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals). + +The accepted snapshot records the published terms the buyer purchased. It does not imply that every direct product is non-guaranteed: discovery mode, buyer composition, and delivery guarantee are independent dimensions. diff --git a/docs/media-buy/task-reference/control_media_buy.mdx b/docs/media-buy/task-reference/control_media_buy.mdx new file mode 100644 index 0000000000..5192de4f00 --- /dev/null +++ b/docs/media-buy/task-reference/control_media_buy.mdx @@ -0,0 +1,43 @@ +--- +title: control_media_buy +description: "Apply operational delivery controls within accepted MediaBuy terms." +"og:title": "AdCP control_media_buy Task" +testable: true +--- + +`control_media_buy` applies operational changes that remain inside the immutable commercial envelope accepted for the MediaBuy. It requires the current `revision` for optimistic concurrency. + +Supported controls include: + +- pause, resume, and an already-permitted unilateral cancellation; +- total and package budget controls; +- budget allocation, pacing, and bidding; +- package targeting, catalog references, keyword, impression, and optimization controls; and +- reporting-webhook configuration. + +It does not accept creatives, creative assignments, new products/packages, flight-date changes, pricing changes, or billing-term changes. Those concerns use their dedicated lifecycle or a proposal refinement. + +{/* Using latest because control_media_buy is not yet released. Update after the 3.2 release. */} +**Request schema:** [`/schemas/latest/media-buy/control-media-buy-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/control-media-buy-request.json) + +```json +{ + "idempotency_key": "550e8400-e29b-41d4-a716-446655441012", + "account": { "account_id": "account_123" }, + "media_buy_id": "media_buy_789", + "revision": 4, + "pacing": "even", + "packages": [ + { + "package_id": "package_video", + "paused": true + } + ] +} +``` + +When an otherwise valid control would exceed the accepted budget, targeting, or delivery envelope, the seller returns [`REQUOTE_REQUIRED`](/docs/building/verification/compliance-catalog#error-code-requote-required). The buyer reads `accepted_proposal_id` from [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys), passes it to [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals), receives a new committed amendment proposal, and applies it through [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal). This pointer survives SDK restart and moves atomically after each accepted amendment. + +`canceled: true` is direct only when the accepted cancellation policy already grants the caller that right. A cancellation requiring counterparty agreement uses `refine_proposals` with `change_kind: "cancellation"`. + +Seller-initiated cancellation does not call a buyer tool. The seller advances the MediaBuy revision and records `cancellation.canceled_by: "seller"`; `get_media_buys` is the normative recovery surface. A durable compact-lifecycle status-change webhook is intentionally not inferred from the per-operation async callback and remains 4.0 work. diff --git a/docs/media-buy/task-reference/create_media_buy.mdx b/docs/media-buy/task-reference/create_media_buy.mdx index e5cc166897..42dbb832a5 100644 --- a/docs/media-buy/task-reference/create_media_buy.mdx +++ b/docs/media-buy/task-reference/create_media_buy.mdx @@ -8,6 +8,10 @@ testable: true Create a media buy from selected packages or execute a proposal. Handles validation, approval if needed, and campaign creation. + +`create_media_buy` is the AdCP 3.x compatibility facade as of 3.2. New integrations use [`buy_products`](/docs/media-buy/task-reference/buy_products) for direct purchases or [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal) for proposal execution. Existing payloads remain supported throughout 3.x. + + Supports three execution shapes: - **Fixed packages**: Provide `packages` with independent package budgets (legacy default) - **Seller-optimized packages**: Provide `packages`, `total_budget`, and `budget_allocation.mode: "seller_optimized"` so the seller allocates a shared budget across packages diff --git a/docs/media-buy/task-reference/decline_proposals.mdx b/docs/media-buy/task-reference/decline_proposals.mdx index fd6a97f57d..dfd81a446b 100644 --- a/docs/media-buy/task-reference/decline_proposals.mdx +++ b/docs/media-buy/task-reference/decline_proposals.mdx @@ -7,7 +7,8 @@ testable: true `decline_proposals` records that the buyer evaluated one or more proposals and will not pursue them. The operation is terminal: a seller must reject later [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) or new logical [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) attempts for a declined proposal with [`INVALID_STATE`](/docs/building/verification/compliance-catalog#error-code-invalid-state). Exact retries of an earlier successful call still follow the shared idempotency replay contract. A proposal that was already executed cannot subsequently be declined and returns `unable`. -**Request schema:** [`/schemas/v3/media-buy/decline-proposals-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/decline-proposals-request.json) +{/* Using latest because decline_proposals is not yet released. Update after the 3.2 release. */} +**Request schema:** [`/schemas/latest/media-buy/decline-proposals-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/decline-proposals-request.json) ```json { diff --git a/docs/media-buy/task-reference/get_media_buys.mdx b/docs/media-buy/task-reference/get_media_buys.mdx index d5558e909b..65f1351c40 100644 --- a/docs/media-buy/task-reference/get_media_buys.mdx +++ b/docs/media-buy/task-reference/get_media_buys.mdx @@ -57,6 +57,9 @@ Returns an array of media buys with current status, creative approval state, and | Field | Description | |-------|-------------| | `media_buy_id` | Seller's media buy identifier | +| `accepted_proposal_id` | Current immutable commercial snapshot used to begin [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals); present for compact-lifecycle buys. | +| `accepted_proposal_terms_digest` | Digest binding the current accepted proposal terms for recovery and governance verification. | +| `accepted_proposal` | Complete accepted canonical proposal for compact-lifecycle buys. Together with the ID and digest, this lets a restarted client refine the current commercial envelope without reconstructing historical offers. | | `invoice_recipient` | Per-buy invoice recipient when provided at creation. Confirms the seller accepted the billing override. Bank details are omitted (write-only). | | `status` | Current status (`pending_creatives`, `pending_start`, `active`, `paused`, `completed`, `rejected`, `canceled`). Maps to `media_buy_status` on `create_media_buy` / `update_media_buy` success responses (3.1 vocabulary note above). | | `status_as_of` | ISO 8601 timestamp when the seller last refreshed the returned media-buy-level `status` from its source of truth. For rolled-up statuses, this MUST NOT be later than the oldest upstream status observation that could affect the returned roll-up. Optional; omitted or `null` makes no freshness assertion, and buyers MUST NOT infer live status from absence. Use this to interpret cached or rolled-up statuses separately from `updated_at`, which means the media buy's last modification time. | @@ -68,8 +71,9 @@ Returns an array of media buys with current status, creative approval state, and | `creative_deadline` | Creative upload deadline (ISO 8601) | | `confirmed_at` | ISO 8601 timestamp when the seller committed to this media buy. May be `null` until seller commitment occurs in deferred/manual approval flows; stable after it is set. | | `cancellation` | Cancellation metadata (present only when `status` is `canceled`). Object with `canceled_at` (ISO 8601), `canceled_by` (`"buyer"` or `"seller"`), and optional `reason`. | -| `revision` | Current revision number. Pass in `update_media_buy` for optimistic concurrency. | +| `revision` | Current revision number. Pass in [`control_media_buy`](/docs/media-buy/task-reference/control_media_buy) or the 3.x `update_media_buy` facade for optimistic concurrency. | | `valid_actions` | Actions the buyer can perform in the current state (e.g., `["pause", "cancel", "update_budget"]`). See [valid actions mapping](#valid-actions-mapping). | +| `available_actions` | Canonical routed actions for compact-lifecycle buys, including whether each action uses [`control_media_buy`](/docs/media-buy/task-reference/control_media_buy), proposal refinement, or another task. Required whenever `accepted_proposal` is present. | | `history` | Revision history entries, most recent first. Only present when `include_history > 0`. Append-only — entries are never modified or deleted. | | `webhook_activity` | Recent reporting and health webhook fires for the calling principal, most-recent first. Only present when `include_webhook_activity` is true AND the seller surfaces fire history for this buy. See [Webhook activity](#webhook-activity) for three-state presence semantics. | | `context` | Opaque media-buy-level correlation data echoed unchanged from `create_media_buy`. Sellers MUST include persisted context when the media buy was created through AdCP with context, and MAY omit it for media buys created outside AdCP or without context. Use it to reconcile `media_buy_id` with buyer tracking state. | diff --git a/docs/media-buy/task-reference/index.mdx b/docs/media-buy/task-reference/index.mdx index 229f376360..96e8d2664d 100644 --- a/docs/media-buy/task-reference/index.mdx +++ b/docs/media-buy/task-reference/index.mdx @@ -17,8 +17,11 @@ Complete reference for all AdCP Media Buy tasks. Each task is designed for AI ag | [`request_proposals`](/docs/media-buy/task-reference/request_proposals) | Request seller-authored plans | ~60s | Planning | | [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) | Create immutable proposal revisions | ~60s | Planning | | [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals) | Record terminal proposal feedback | ~1s | Planning | -| [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) | Create campaigns from selected products | Minutes-Days | Media Buys | -| [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy) | Modify campaign settings and budgets | Minutes-Days | Media Buys | +| [`buy_products`](/docs/media-buy/task-reference/buy_products) | Buy published offers directly | Minutes-Days | Media Buys | +| [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal) | Accept new-buy, amendment, or cancellation terms | Minutes-Days | Media Buys | +| [`control_media_buy`](/docs/media-buy/task-reference/control_media_buy) | Apply operational delivery controls | Minutes-Days | Media Buys | +| [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) | 3.x compatibility creation facade | Minutes-Days | Compatibility | +| [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy) | 3.x compatibility update facade | Minutes-Days | Compatibility | | [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities) | View endpoint capabilities, including creative operations | ~1s | Capability | | [`sync_catalogs`](/docs/media-buy/task-reference/sync_catalogs) | Sync catalog feeds (products, stores, inventory) | Minutes-Days | Catalogs | | [`sync_creatives`](/docs/creative/task-reference/sync_creatives) | Upload and manage creative assets | Minutes-Days | Creatives | @@ -76,8 +79,10 @@ Start here to understand what's available and plan your campaign. ### Media Buy Management Create and manage your advertising campaigns. -- **[`create_media_buy`](/docs/media-buy/task-reference/create_media_buy)** - Create campaigns from discovered products -- **[`update_media_buy`](/docs/media-buy/task-reference/update_media_buy)** - Modify budgets, targeting, and settings +- **[`buy_products`](/docs/media-buy/task-reference/buy_products)** - Directly buy published product offers +- **[`accept_proposal`](/docs/media-buy/task-reference/accept_proposal)** - Apply negotiated new-buy, amendment, or cancellation terms +- **[`control_media_buy`](/docs/media-buy/task-reference/control_media_buy)** - Modify delivery controls inside accepted terms +- **[`create_media_buy`](/docs/media-buy/task-reference/create_media_buy)** and **[`update_media_buy`](/docs/media-buy/task-reference/update_media_buy)** - Deprecated 3.x compatibility facades ### Catalog Management Sync product feeds, inventory, and store data to seller accounts. @@ -89,7 +94,7 @@ Handle creative assets throughout their lifecycle. - **[`sync_creatives`](/docs/creative/task-reference/sync_creatives)** - Upload assets to an agent-hosted creative library - **[`list_creatives`](/docs/creative/task-reference/list_creatives)** - Search and manage your creative library (creative protocol) -- **Inline `packages[].creatives`** - Attach or replace package-scoped creative bodies when the seller advertises `inline_creative_management` without a creative library +- **Inline `packages[].creatives`** - Deprecated 3.x compatibility path available only through legacy create/update facades; absent from the compact lifecycle ### Performance & Optimization Monitor and optimize campaign performance. diff --git a/docs/media-buy/task-reference/list_products.mdx b/docs/media-buy/task-reference/list_products.mdx index d964e94866..83bae9631d 100644 --- a/docs/media-buy/task-reference/list_products.mdx +++ b/docs/media-buy/task-reference/list_products.mdx @@ -5,9 +5,10 @@ description: "Read seller product offers with structured discovery criteria." testable: true --- -`list_products` is the side-effect-free entry point to the compact product lifecycle. It returns products only—never proposals—and always completes synchronously. Buyers can purchase a listed product directly or pass returned `product_id` values to [`request_proposals`](/docs/media-buy/task-reference/request_proposals) for seller planning. +`list_products` is the side-effect-free entry point to the compact product lifecycle. Each invocation completes synchronously, while account-level wholesale feed webhooks keep long-lived buyer mirrors current without polling. It returns products only—never proposals. Buyers can purchase listed products through [`buy_products`](/docs/media-buy/task-reference/buy_products) or pass returned `product_id` values to [`request_proposals`](/docs/media-buy/task-reference/request_proposals) for seller planning. -**Request schema:** [`/schemas/v3/media-buy/list-products-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/list-products-request.json) +{/* Using latest because list_products is not yet released. Update after the 3.2 release. */} +**Request schema:** [`/schemas/latest/media-buy/list-products-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/list-products-request.json) ```json { @@ -79,3 +80,16 @@ If the selected feed and pricing layer still match, the seller can omit `product "cache_scope": "account" } ``` + +## Wholesale feed webhooks + +Buyers maintaining a wholesale product mirror register `product.*` and `wholesale_feed.bulk_change` subscribers through `sync_accounts.accounts[].notification_configs[]`. Registration is account-level and durable; `push_notification_config` on an individual `list_products` request does not create that subscription. + +The normal mirror flow is: + +1. Call `list_products` to bootstrap the mirror and store `feed_version` plus `cache_scope`. +2. Register the durable account subscriber through [`sync_accounts`](/docs/accounts/tasks/sync_accounts). +3. Apply the webhook's `canonical_product` or `canonical_pricing_options` replacement payload directly. +4. On a gap, call `list_products` with the mirror's last successfully applied `feed_version`. On an uncertain or bulk-change repair, omit the conditional token and replace the mirror from a complete read. + +The webhook version describes post-change state; it is not the conditional token for a stale mirror. Conditional reads are a repair path, not a requirement to poll while webhooks are healthy. diff --git a/docs/media-buy/task-reference/refine_proposals.mdx b/docs/media-buy/task-reference/refine_proposals.mdx index 04e91100dd..afebf0c4ff 100644 --- a/docs/media-buy/task-reference/refine_proposals.mdx +++ b/docs/media-buy/task-reference/refine_proposals.mdx @@ -5,9 +5,10 @@ description: "Create executable revisions from one or more proposals." testable: true --- -`refine_proposals` creates executable committed revisions directly. Each entry targets one proposal, proposal IDs must be unique within the request, and results preserve request order. Successful refinement returns a new `proposal_id`; that ID is the complete version identity for the immutable commercial snapshot, and the source remains addressable for comparison or parallel exploration. +`refine_proposals` creates executable committed revisions directly. Each entry targets one draft, committed, or accepted proposal. Proposal IDs must be unique within the request, and results preserve request order. Successful refinement returns a new `proposal_id`; that ID is the complete version identity for the immutable commercial snapshot, and the source remains unchanged. -**Request schema:** [`/schemas/v3/media-buy/refine-proposals-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/refine-proposals-request.json) +{/* Using latest because accepted-proposal refinement is not yet released. Update after the 3.2 release. */} +**Request schema:** [`/schemas/latest/media-buy/refine-proposals-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/refine-proposals-request.json) ```json { @@ -25,6 +26,25 @@ testable: true } ``` -The response contains one ordered `results[]` entry per request. `revised` means the requested change was applied, `partial` returns an honest executable revision plus explanatory notes, and `unable` returns a reason without minting a misleading revision. Every returned revision is an immutable committed snapshot that can be passed directly to [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy), or to [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals) when the buyer stops pursuing it. +The response contains one ordered `results[]` entry per request. `revised` means the requested change was applied, `partial` returns an honest executable revision plus explanatory notes, and `unable` returns a reason without minting a misleading revision. Every returned revision carries typed `commercial_terms` and a `terms_digest`. Pass a new-buy proposal to [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal), or use [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals) when the buyer stops pursuing it. The deprecated [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) proposal mode remains only as the 3.x compatibility adapter. Every revised proposal inherits the source proposal's opportunity association. Callers do not repeat opportunity context during refinement. + +## Amend an accepted MediaBuy + +An accepted proposal is the immutable commercial envelope attached to its MediaBuy. Refining it forks a successor proposal rather than reopening or mutating the accepted snapshot: + +```json +{ + "idempotency_key": "550e8400-e29b-41d4-a716-446655441013", + "refinements": [ + { + "proposal_id": "proposal_accepted_123", + "change_kind": "amendment", + "instructions": "Extend the flight by two weeks and add the premium video product." + } + ] +} +``` + +Use `change_kind: "cancellation"` when cancellation requires counterparty agreement. The returned proposal has `proposal_kind: "media_buy_cancellation"`, identifies the MediaBuy and base revision, and is applied only through [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal). diff --git a/docs/media-buy/task-reference/request_proposals.mdx b/docs/media-buy/task-reference/request_proposals.mdx index 64a38bad31..4eaad60964 100644 --- a/docs/media-buy/task-reference/request_proposals.mdx +++ b/docs/media-buy/task-reference/request_proposals.mdx @@ -9,7 +9,8 @@ testable: true `brand` is required and contains only the stable BrandKey (`domain` plus optional `brand_id`); the seller resolves the canonical brand manifest rather than receiving a full brand file in every call. `account_id` is optional and adds seller-specific commercial terms when the buyer already knows its account. -**Request schema:** [`/schemas/v3/media-buy/request-proposals-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/request-proposals-request.json) +{/* Using latest because request_proposals is not yet released. Update after the 3.2 release. */} +**Request schema:** [`/schemas/latest/media-buy/request-proposals-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/request-proposals-request.json) ```json { diff --git a/docs/media-buy/task-reference/update_media_buy.mdx b/docs/media-buy/task-reference/update_media_buy.mdx index 982a80914e..7eb31e7539 100644 --- a/docs/media-buy/task-reference/update_media_buy.mdx +++ b/docs/media-buy/task-reference/update_media_buy.mdx @@ -8,6 +8,10 @@ testable: true Modify an existing media buy using PATCH semantics. Supports campaign-level and package-level updates. + +`update_media_buy` is the AdCP 3.x compatibility facade as of 3.2. New integrations use [`control_media_buy`](/docs/media-buy/task-reference/control_media_buy) for operational controls and [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) for commercial amendments or negotiated cancellation. Existing payloads remain supported throughout 3.x. + + **Response Time**: Instant to days (status: `completed`, `working` < 120s, or `submitted` for manual review) ## Scope diff --git a/docs/protocol/calling-an-agent.mdx b/docs/protocol/calling-an-agent.mdx index 1af55332fd..4d07993d08 100644 --- a/docs/protocol/calling-an-agent.mdx +++ b/docs/protocol/calling-an-agent.mdx @@ -31,6 +31,8 @@ Every mutating tool requires an `idempotency_key` (UUID). For async flows, the replayed response carries the **same `task_id`** so polling continues against the same task instead of forking. +The compact 3.2 lifecycle requires `idempotency_key` on [`request_proposals`](/docs/media-buy/task-reference/request_proposals), [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals), [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals), [`buy_products`](/docs/media-buy/task-reference/buy_products), [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal), and [`control_media_buy`](/docs/media-buy/task-reference/control_media_buy). [`list_products`](/docs/media-buy/task-reference/list_products) remains a synchronous key-optional read. + `idempotency_key` is required on: [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy), [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy), [`sync_creatives`](/docs/creative/task-reference/sync_creatives), [`sync_audiences`](/docs/media-buy/task-reference/sync_audiences), [`sync_accounts`](/docs/accounts/tasks/sync_accounts), [`sync_catalogs`](/docs/media-buy/task-reference/sync_catalogs), [`sync_event_sources`](/docs/media-buy/task-reference/sync_event_sources), [`sync_plans`](/docs/governance/campaign/tasks/sync_plans), [`sync_governance`](/docs/accounts/tasks/sync_governance), [`activate_signal`](/docs/signals/tasks/activate_signal), [`acquire_rights`](/docs/brand-protocol/tasks/acquire_rights), [`log_event`](/docs/media-buy/task-reference/log_event), [`report_usage`](/docs/accounts/tasks/report_usage), [`provide_performance_feedback`](/docs/media-buy/task-reference/provide_performance_feedback), [`report_plan_outcome`](/docs/governance/campaign/tasks/report_plan_outcome), [`create_property_list`](/docs/governance/property/tasks/property_lists#create_property_list), [`update_property_list`](/docs/governance/property/tasks/property_lists#update_property_list), [`delete_property_list`](/docs/governance/property/tasks/property_lists#delete_property_list), [`create_collection_list`](/docs/governance/collection/tasks/collection_lists#create_collection_list), [`update_collection_list`](/docs/governance/collection/tasks/collection_lists#update_collection_list), [`delete_collection_list`](/docs/governance/collection/tasks/collection_lists#delete_collection_list), [`create_content_standards`](/docs/governance/content-standards/tasks/create_content_standards), [`update_content_standards`](/docs/governance/content-standards/tasks/update_content_standards), [`calibrate_content`](/docs/governance/content-standards/tasks/calibrate_content), [`si_initiate_session`](/docs/sponsored-intelligence/tasks/si_initiate_session), [`si_send_message`](/docs/sponsored-intelligence/tasks/si_send_message). Missing the key → `adcp_error.code: 'VALIDATION_ERROR'` with `/idempotency_key` in `issues`. diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index 422696ff77..23a8dc9ad2 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -181,7 +181,7 @@ Declares cross-role enforcement of buyer governance for consequential tasks. Thi } ``` -The declaration is part of the experimental `governance.campaign` surface, so an implementing service also lists `governance.campaign` in top-level `experimental_features`. `signed_context` is defined for every governed role. `online_execution_check` is currently available only for [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) and [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy), where `planned_delivery` provides a standardized prepared-result shape; it always implies `signed_context`. The state transition follows prepare → check → commit and commits atomically only when the response is `approved`. Execution checks are binary (`approved` or `denied`). +The declaration is part of the experimental `governance.campaign` surface, so an implementing service also lists `governance.campaign` in top-level `experimental_features`. `signed_context` is defined for every governed role. `online_execution_check` is available for the legacy [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) / [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy) facades and the compact [`buy_products`](/docs/media-buy/task-reference/buy_products), [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal), and [`control_media_buy`](/docs/media-buy/task-reference/control_media_buy) lifecycle, where `planned_delivery` provides a standardized prepared-result shape. Proposal acceptance binds both `proposal_id` and `proposal_terms_digest`. Online checking always implies `signed_context`; the state transition follows prepare → check → commit and commits atomically only when the response is `approved`. Advertising a task also commits the service to deterministic applicability. It resolves the commercial account from the request or existing resource before the side effect; when it cannot do so, it returns [`ACCOUNT_REQUIRED`](/docs/building/verification/compliance-catalog#error-code-account-required). It never treats a missing token as evidence that the buyer is ungoverned. Conditional task annotations use `trigger_overrides_exemption`: any commitment-increasing part of a mixed atomic update requires governance. @@ -364,9 +364,9 @@ See [Accounts and Agents](/docs/building/by-layer/L2/accounts-and-agents#what-se Media-buy protocol capabilities. Only present if `media_buy` is in `supported_protocols`. Sellers declaring `media_buy` should also include `account` (with `supported_billing`) and `media_buy.portfolio` — buyers need both to establish billing and understand inventory coverage. Compliance testing validates their presence. -#### product_discovery_tools +#### lifecycle_tools -AdCP 3.2 sellers use `product_discovery_tools` to advertise any supported subset of `get_products`, `list_products`, `request_proposals`, `refine_proposals`, and `decline_proposals`. When absent, buyers use the legacy `get_products` facade and inspect `buying_modes`. The compact tools share the authorization framework with legacy discovery, but grants remain task-specific and each stateful tool has its own idempotency identity: retry with the same tool name and payload. SDKs use the release manifest's `legacy_fallback` metadata to distinguish a direct one-call translation from a stateful orchestration or an unsupported fallback; see [Same-major tool replacements](/docs/building/cross-cutting/version-adaptation#same-major-tool-replacements). +AdCP 3.2 sellers use `lifecycle_tools` to advertise any supported subset of `get_products`, `list_products`, `request_proposals`, `refine_proposals`, `decline_proposals`, `buy_products`, `accept_proposal`, and `control_media_buy`. When absent, buyers use the legacy `get_products`, `create_media_buy`, and `update_media_buy` facades. The compact tools share the authorization framework with their legacy peers, but grants remain task-specific and each stateful tool has its own idempotency identity: retry with the same tool name and payload. SDKs use the release manifest's `legacy_fallback` metadata to distinguish a direct one-call translation from a stateful orchestration or an unsupported fallback; see [Same-major tool replacements](/docs/building/cross-cutting/version-adaptation#same-major-tool-replacements). :::note 3.0 breaking changes The following fields have been removed from the capabilities response: @@ -412,7 +412,7 @@ Optional media-buy features. Boolean declarations are commitments when true. Str | Feature | Description | |---------|-------------| -| `inline_creative_management` | Accepts creatives inline in `create_media_buy` and `update_media_buy` package payloads | +| `inline_creative_management` | Deprecated 3.x compatibility capability for inline creatives in `create_media_buy` and `update_media_buy`; compact lifecycle tools never accept them | | `property_list_filtering` | Honors `property_list` parameter in `get_products` | | `catalog_management` | Supports [`sync_catalogs`](/docs/media-buy/task-reference/sync_catalogs) for catalog feed management | | `seller_optimized_budget` | Supports shared media-buy budgets and cross-package allocation goals | diff --git a/docs/reference/release-notes.mdx b/docs/reference/release-notes.mdx index 411bb8cb87..01a2ffc892 100644 --- a/docs/reference/release-notes.mdx +++ b/docs/reference/release-notes.mdx @@ -13,22 +13,24 @@ Authoritative version-by-version release record for AdCP, with cumulative change **Status:** In development — minor release targeting the 3.2.0 milestone. Stable wire shapes remain backward compatible; explicitly experimental surfaces may carry noticed changes under the [experimental-status contract](/docs/reference/experimental-status). -### Compact product and proposal lifecycle (#6115) +### Compact product and MediaBuy lifecycle (#6115) -AdCP 3.2 adds `list_products`, `request_proposals`, `refine_proposals`, and `decline_proposals` as compact, task-specific contracts and retains `get_products` throughout 3.x. +AdCP 3.2 adds `list_products`, `request_proposals`, `refine_proposals`, `decline_proposals`, `buy_products`, `accept_proposal`, and `control_media_buy` as compact, task-specific contracts. The broad `get_products`, `create_media_buy`, and `update_media_buy` facades remain functional throughout 3.x but are deprecated for new integrations. - `list_products` is a synchronous, naturally idempotent product-offer read. - `request_proposals` creates executable proposals with firm terms from a brief and optional listed product IDs. - `request_proposals` and `refine_proposals` return immutable, executable proposal snapshots; `decline_proposals` records terminal buyer feedback and prevents later execution. -- `proposal_id` is the complete immutable snapshot identity, so changed terms mint a new ID rather than adding `proposal_version` to every downstream call. -- Optional shared `opportunity` context connects proposal request, refinement, decline, and purchase without expanding proposal snapshot identity. +- `buy_products` accepts published product offers directly; `accept_proposal` applies new-buy, amendment, or negotiated-cancellation proposals; `control_media_buy` handles revision-checked operational controls inside accepted terms. +- Canonical proposals carry typed `commercial_terms` plus an RFC 8785 SHA-256 `terms_digest`. Changed terms mint a new `proposal_id`; accepted MediaBuys expose the current proposal pointer for restart-safe refinement. +- New purchase and control tools never accept inline creatives. Creative bodies and assignments use the dedicated creative lifecycle. +- Optional shared `opportunity` context connects proposal request, refinement, decline, and purchase; proposal snapshots retain the association without making it proposal identity. - Each stateful task requires an idempotency key and retains its own replay identity. Retries use the same tool name. - Brand input is an identity-only key, catalogs are compact selections, and offer filters are separated from exact targeting and future targeting support. -- `media_buy.product_discovery_tools` advertises the split surface. Its absence means callers use `get_products` and `buying_modes`. +- `media_buy.lifecycle_tools` advertises the compact product and MediaBuy lifecycle. Its absence means callers use the 3.x compatibility facades. - The release manifest labels each legacy fallback as `direct`, `orchestrated`, or `none`, so SDKs can expose the split API without claiming lossy calls are aliases. - `task_result_resolution` maps each tracked `task_type` to its tool `response_schema` or a retained 3.x event override, letting SDKs generate `TaskStatus` without embedding the global async-result union in `getTaskStatus`. -New integrations SHOULD use the split tools. Existing integrations do not need an immediate migration: `get_products` keeps its optional-key 3.x contract, and sellers MUST honor replay protection whenever a key is supplied. +New integrations SHOULD use the compact tools. Existing integrations do not need an immediate migration: the three compatibility facades retain their 3.x contracts, and sellers MUST honor replay protection whenever a key is supplied. ### Inline asset-access credentials deprecated (#5698) diff --git a/docs/snippets/compliance-error-codes.mdx b/docs/snippets/compliance-error-codes.mdx index dbdd26305f..e40f86158c 100644 --- a/docs/snippets/compliance-error-codes.mdx +++ b/docs/snippets/compliance-error-codes.mdx @@ -67,7 +67,7 @@ description: "Canonical AdCP error codes with recovery classifications, remediat | `INVALID_REQUEST` | correctable | check request parameters and fix | | `INVALID_STATE` | correctable | check current status via get_media_buys and adjust request | | `INVALID_USAGE_DATA` | correctable | check required fields for the vendor type (vendor_cost, currency, account at minimum), fix invalid values, and resubmit | -| `IO_REQUIRED` | correctable | review the proposal's insertion_order, accept terms, and include io_acceptance on create_media_buy | +| `IO_REQUIRED` | correctable | review the proposal's insertion_order, accept terms, and include io_acceptance on accept_proposal; use create_media_buy only for 3.x compatibility | | `ITEM_VALIDATION_FAILED` | correctable | check item_issues for per-item rejection reasons and fix the offending items | | `MEDIA_BUY_NOT_FOUND` | correctable | verify media_buy_id; for legacy correlation use get_media_buys plus context, such as context.internal_campaign_id | | `MULTI_FINALIZE_UNSUPPORTED` | correctable | sequence single-proposal finalize calls — one finalize entry per get_products call | @@ -95,7 +95,7 @@ description: "Canonical AdCP error codes with recovery classifications, remediat | `RATE_LIMITED` | transient | wait top-level error.retry_after seconds when present, then retry | | `READ_ONLY_SCOPE` | correctable | use a non-mutating alternative, or surface to the operator to request a scope that permits mutation | | `REFERENCE_NOT_FOUND` | correctable | verify the referenced identifier exists and is accessible to the caller | -| `REQUOTE_REQUIRED` | correctable | adjust the update to stay within the current quote envelope, rediscover products/terms, add packages when available, or create a separate media buy; 3.1 does not define an amendment-quote artifact for update_media_buy | +| `REQUOTE_REQUIRED` | correctable | refine accepted_proposal_id into an amendment and apply it through accept_proposal, or keep control_media_buy inside the accepted envelope; 3.1 compatibility callers must adjust or rediscover | | `SCOPE_INSUFFICIENT` | correctable | the agent cannot broaden its own scope - surface to the operator rather than auto-retry | | `SERVICE_UNAVAILABLE` | transient | retry with exponential backoff | | `SESSION_NOT_FOUND` | correctable | initiate a new session via si_initiate_session | @@ -655,9 +655,9 @@ A usage record in {"report_usage"} has missing or invalid fields -**Suggested action:** review the proposal's insertion_order, accept terms, and include io_acceptance on create_media_buy +**Suggested action:** review the proposal's insertion_order, accept terms, and include io_acceptance on accept_proposal; use create_media_buy only for 3.x compatibility -The committed proposal requires a signed insertion order but no io_acceptance was provided. Recovery: correctable (review the proposal's insertion_order, accept terms, and include io_acceptance on create_media_buy). +The committed proposal requires a signed insertion order but no io_acceptance was provided. Recovery: correctable (review the proposal's insertion_order, accept terms, and include io_acceptance on accept_proposal; 3.x compatibility callers use create_media_buy). @@ -928,9 +928,9 @@ Generic fallback for a referenced identifier, grant, session, or other resource -**Suggested action:** adjust the update to stay within the current quote envelope, rediscover products/terms, add packages when available, or create a separate media buy; 3.1 does not define an amendment-quote artifact for update_media_buy +**Suggested action:** refine accepted_proposal_id into an amendment and apply it through accept_proposal, or keep control_media_buy inside the accepted envelope; 3.1 compatibility callers must adjust or rediscover -An update_media_buy request changes the parameter envelope (budget, flight dates, volume, targeting) the original quote was priced against. The pricing_option remains locked; the seller is declining the requested shape at that price. Distinct from TERMS_REJECTED (measurement) and POLICY_VIOLATION (content). Sellers SHOULD populate error.details.envelope_field with the field path(s) that breached the envelope (e.g., 'packages[0].budget', 'end_time') so the buyer's agent can decide whether to adjust the update, rediscover products, add packages where supported, or create a separate media buy. AdCP 3.1 does not define an amendment-quote artifact that can be attached to update_media_buy. +A control_media_buy request, or the 3.x update_media_buy facade, would exceed the accepted commercial envelope. The seller is declining the requested shape at the current terms. Distinct from TERMS_REJECTED (measurement) and POLICY_VIOLATION (content). Sellers SHOULD populate error.details.envelope_field with the field path(s) that breached the envelope. AdCP 3.2 callers refine the MediaBuy's accepted_proposal_id to obtain a typed amendment; legacy 3.1 callers adjust the update, rediscover terms, or create a separate buy. diff --git a/server/src/training-agent/idempotency.ts b/server/src/training-agent/idempotency.ts index c04dbf40a8..b0cf4a7a23 100644 --- a/server/src/training-agent/idempotency.ts +++ b/server/src/training-agent/idempotency.ts @@ -57,15 +57,18 @@ const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9_.:-]{16,255}$/; * between this list and the schemas will fail CI. */ export const MUTATING_TOOLS: ReadonlySet = new Set([ + 'accept_proposal', 'acquire_rights', 'activate_signal', 'build_creative', + 'buy_products', 'calibrate_content', 'create_collection_list', 'create_content_standards', 'create_media_buy', 'create_property_list', 'creative_approval', + 'control_media_buy', 'delete_collection_list', 'delete_property_list', 'decline_proposals', diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 5f375efbed..8329df7990 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -4538,14 +4538,18 @@ export function validateProductDiscoveryAliasInput( toolName: string, args: Record, ): { message: string; field?: string } | undefined { + const account = isRecord(args.account) ? args.account : undefined; + const hasNaturalAccountBrand = isRecord(account?.brand) + && typeof account.operator === 'string' + && account.operator.length > 0; const allowedFields: Record> = { list_products: new Set([ 'adcp_version', 'adcp_major_version', 'idempotency_key', 'context_id', - 'context', 'governance_context', 'push_notification_config', 'account_id', 'brand', 'criteria', + 'context', 'governance_context', 'push_notification_config', 'account', 'brand', 'criteria', 'fields', 'cursor', 'max_results', 'if_feed_version', 'if_pricing_version', ]), request_proposals: new Set([ - 'adcp_version', 'adcp_major_version', 'idempotency_key', 'account_id', + 'adcp_version', 'adcp_major_version', 'idempotency_key', 'account', 'context_id', 'context', 'governance_context', 'push_notification_config', 'brand', 'brief', 'criteria', 'opportunity', ]), @@ -4578,7 +4582,7 @@ export function validateProductDiscoveryAliasInput( return { message: 'if_pricing_version requires if_feed_version', field: 'if_feed_version' }; } const criteria = isRecord(args.criteria) ? args.criteria : undefined; - if (isRecord(criteria?.catalog) && args.brand === undefined) { + if (isRecord(criteria?.catalog) && args.brand === undefined && !hasNaturalAccountBrand) { return { message: 'brand is required when catalog criteria are present', field: 'brand' }; } if (isRecord(criteria?.catalog) && typeof criteria.catalog.catalog_id !== 'string') { @@ -4589,11 +4593,11 @@ export function validateProductDiscoveryAliasInput( if (typeof args.brief !== 'string' || args.brief.length === 0) { return { message: 'brief is required for request_proposals', field: 'brief' }; } - if (args.brand === undefined) { + if (args.brand === undefined && !hasNaturalAccountBrand) { return { message: 'brand is required for request_proposals', field: 'brand' }; } const criteria = isRecord(args.criteria) ? args.criteria : undefined; - if (isRecord(criteria?.catalog) && args.brand === undefined) { + if (isRecord(criteria?.catalog) && args.brand === undefined && !hasNaturalAccountBrand) { return { message: 'brand is required when catalog criteria are present', field: 'brand' }; } if (isRecord(criteria?.catalog) && typeof criteria.catalog.catalog_id !== 'string') { @@ -9702,7 +9706,7 @@ export async function handleGetAdcpCapabilities(args: ToolArgs, ctx: TrainingCon media_buy: { buying_modes: wholesaleProfile.productWholesale ? ['brief', 'wholesale', 'refine'] : ['brief', 'refine'], ...(supportsGetProductsRejected(servedAdcpVersion) && { - product_discovery_tools: [...PRODUCT_DISCOVERY_TOOLS], + lifecycle_tools: [...PRODUCT_DISCOVERY_TOOLS], }), supports_proposals: true, features: { diff --git a/server/src/training-agent/tenants/router.ts b/server/src/training-agent/tenants/router.ts index 7230e06e14..a468a4c9f0 100644 --- a/server/src/training-agent/tenants/router.ts +++ b/server/src/training-agent/tenants/router.ts @@ -828,7 +828,7 @@ function projectSalesCapabilities( ...mediaBuy, ...salesProjection, ...(supportsGetProductsRejected(servedVersion) && { - product_discovery_tools: [...PRODUCT_DISCOVERY_TOOL_NAMES], + lifecycle_tools: [...PRODUCT_DISCOVERY_TOOL_NAMES], }), features: { ...( diff --git a/server/tests/unit/training-agent-idempotency.test.ts b/server/tests/unit/training-agent-idempotency.test.ts index d252744fe3..cd9801b36a 100644 --- a/server/tests/unit/training-agent-idempotency.test.ts +++ b/server/tests/unit/training-agent-idempotency.test.ts @@ -164,7 +164,7 @@ describe('training agent idempotency middleware', () => { }); const list = await call(server, 'list_products', { - account_id: 'idem-account', + account: { account_id: 'idem-account' }, max_results: 0, }); expect(list.isError).toBe(true); @@ -176,7 +176,7 @@ describe('training agent idempotency middleware', () => { it('accepts and ignores callback envelope configuration on synchronous list_products', async () => { const result = await call(server, 'list_products', { - account_id: 'idem-account', + account: { account_id: 'idem-account' }, push_notification_config: { url: 'https://callbacks.example/list-products', operation_id: 'list-products-wrapper-envelope', diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index f8129890f0..c321d6334e 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -13242,7 +13242,8 @@ describe('proposal lifecycle', () => { const originalBrand = { domain: 'proposal-house.example', brand_id: 'alpha' }; const siblingBrand = { domain: 'proposal-house.example', brand_id: 'beta' }; const { result: requested, isError: requestError } = await simulateCallTool(server, 'request_proposals', { - account_id: 'proposal-account-alpha', + idempotency_key: 'compact-brand-binding-request-1', + account: { account_id: 'proposal-account-alpha' }, brand: originalBrand, brief: 'social engagement display', }); @@ -13280,6 +13281,21 @@ describe('proposal lifecycle', () => { expect(accepted.result.media_buy_id).toEqual(expect.any(String)); }); + it('accepts a natural-key account as the sole request_proposals brand source', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const naturalAccount = { + brand: { domain: 'natural-proposal-brand.example', brand_id: 'primary' }, + operator: 'proposal-operator.example', + }; + const { result, isError } = await simulateCallTool(server, 'request_proposals', { + account: naturalAccount, + brief: 'social engagement display', + }); + + expect(isError).toBeFalsy(); + expect(result).toMatchObject({ outcome: 'proposed', proposals: expect.any(Array) }); + }); + async function getProductsWithProposals() { const server = createTrainingAgentServer(DEFAULT_CTX); const { result } = await simulateCallTool(server, 'get_products', { diff --git a/specs/wholesale-feed-webhooks.md b/specs/wholesale-feed-webhooks.md index ad62772a10..3f1137d20b 100644 --- a/specs/wholesale-feed-webhooks.md +++ b/specs/wholesale-feed-webhooks.md @@ -2,21 +2,21 @@ ## Problem -Sales agents and signals agents publish wholesale product feeds (`get_products buying_mode: "wholesale"`) and wholesale signals feeds (`get_signals discovery_mode: "wholesale"`) that consumers want to mirror locally. Wholesale enumeration plus `wholesale_feed_version` lets consumers poll cheaply, but polling still has two gaps: +Sales agents and signals agents publish wholesale product feeds (`list_products`, with deprecated `get_products buying_mode: "wholesale"` retained throughout 3.x) and wholesale signals feeds (`get_signals discovery_mode: "wholesale"`) that consumers want to mirror locally. Wholesale enumeration plus a feed version makes reconciliation cheap, but polling still has two gaps: -**Terminology note.** This spec deliberately uses **wholesale feed** for seller-side products and signals exposed by `get_products` / `get_signals`. That is distinct from `sync_catalogs`, which pushes buyer-provided campaign inputs such as product feeds, stores, and hotel feeds into a seller account. +**Terminology note.** This spec deliberately uses **wholesale feed** for seller-side products and signals exposed by `list_products` / `get_signals` (and the 3.x `get_products` compatibility facade). That is distinct from `sync_catalogs`, which pushes buyer-provided campaign inputs such as product feeds, stores, and hotel feeds into a seller account. 1. **Latency.** A seller that changes pricing or removes a product cannot tell mirrors immediately unless the mirror polls. -2. **Unnecessary full reads.** A consumer that receives only a generic "something changed" notice still has to call `get_products` / `get_signals` to learn what changed. +2. **Unnecessary full reads.** A consumer that receives only a generic "something changed" notice still has to call `list_products` / `get_signals` to learn what changed. 3. **No native push payload.** Buyers need a standard webhook body that carries the changed product or signal, with enough versioning metadata to repair through the authoritative read tasks when needed. -This spec defines account-level wholesale feed webhooks. Webhooks carry the actual change payload. `get_products` and `get_signals` remain the authoritative repair and reconciliation paths. +This spec defines account-level wholesale feed webhooks. Webhooks carry the actual change payload. `list_products` and `get_signals` are the authoritative repair and reconciliation paths; `get_products` remains the 3.x product compatibility path. ## Goals 1. Consumers can register account-level webhooks and receive the changed product, signal, or bulk-change summary directly. 2. Consumers can update local wholesale product-feed and signals-feed mirrors without immediately re-reading the full feed after every change. -3. Consumers can detect uncertainty and repair by calling `get_products` / `get_signals` with `if_wholesale_feed_version`. +3. Consumers can detect uncertainty and repair by calling `list_products` with `if_feed_version`, or `get_signals` with `if_wholesale_feed_version`. 4. The mechanism is symmetric for sales agents and signals agents. 5. No new AdCP task is introduced for event polling. @@ -24,7 +24,7 @@ This spec defines account-level wholesale feed webhooks. Webhooks carry the actu - No REST endpoint. - No event-polling task. -- No durable cursor API or retention-window error. Missed pushes are repaired through `get_products` / `get_signals`. +- No durable cursor API or retention-window error. Missed pushes are repaired through `list_products` / `get_signals`. ## Event Types @@ -32,9 +32,9 @@ Wholesale feed webhooks use the shared `notification-type.json` enum and registe | Event | Trigger | Webhook payload | |-------|---------|-----------------| -| `product.created` | New product added to the wholesale product feed | Full post-change `Product` object | -| `product.updated` | Product metadata changed | Full post-change `Product` object, plus advisory `changed_fields[]` | -| `product.priced` | Pricing options changed | Full post-change `pricing_options[]`, retired `previous_pricing_option_ids[]`, optional `effective_at` | +| `product.created` | New product added to the wholesale product feed | Post-change `canonical_product` for `list_products` subscribers or legacy `product` for `get_products` subscribers | +| `product.updated` | Product metadata changed | Negotiated post-change product view, plus advisory `changed_fields[]` | +| `product.priced` | Pricing options changed | `canonical_pricing_options[]` for `list_products` or legacy `pricing_options[]`, plus retired IDs and optional `effective_at` | | `product.removed` | Product no longer available | Product id, `removal_reason`, and affected cache layer | | `signal.created` | New signal added to the wholesale signals feed | Full post-change signal object | | `signal.updated` | Signal metadata changed | Full post-change signal object, plus advisory `changed_fields[]` | @@ -42,7 +42,7 @@ Wholesale feed webhooks use the shared `notification-type.json` enum and registe | `signal.removed` | Signal no longer available | Signal id, `removal_reason`, and affected cache layer | | `wholesale_feed.bulk_change` | Bulk operation too large/noisy for per-entity pushes against one feed | Summary, affected feed type, and repair recommendation | -`wholesale_feed.bulk_change` is the fast-forward event. Sellers SHOULD emit it when one operation affects **>5% of a wholesale feed** or **>100 entities**, whichever is smaller. A single bulk-change webhook describes either the wholesale product feed or the wholesale signals feed, not both, so the envelope's `wholesale_feed_version` is unambiguous. If one operation changes both feeds, the seller MUST emit one webhook per feed. Receivers SHOULD repair by re-reading the affected wholesale feed via `get_products` or `get_signals`. +`wholesale_feed.bulk_change` is the fast-forward event. Sellers SHOULD emit it when one operation affects **>5% of a wholesale feed** or **>100 entities**, whichever is smaller. A single bulk-change webhook describes either the wholesale product feed or the wholesale signals feed, not both, so the envelope's `wholesale_feed_version` is unambiguous. If one operation changes both feeds, the seller MUST emit one webhook per feed. Receivers repair products through `list_products` (`get_products` remains the deprecated 3.x fallback) and signals through `get_signals`. ## Webhook Payload @@ -94,9 +94,9 @@ Example `product.priced` fire: `*.priced` events MAY include `effective_at`. When `effective_at` is absent or in the past, the pricing payload is the current active pricing for the affected wholesale feed layer. When `effective_at` is in the future, the event is a pre-announcement: receivers MAY store the pricing payload as pending state and warm caches, but MUST NOT use it for spend-binding decisions until the effective time has passed. -At or after the effective time, receivers MAY promote the pending pricing payload into the mirror if no retraction was received. Before any action that commits spend or authority, receivers still MUST repair through `get_products` / `get_signals`; the webhook payload is a cache update, not binding authority. +At or after the effective time, receivers MAY promote the pending pricing payload into the mirror if no retraction was received. Before any action that commits spend or authority, receivers still MUST repair through `list_products` / `get_signals` (or deprecated `get_products` for a legacy subscriber); the webhook payload is a cache update, not binding authority. -A `*.priced` event with `retracts_event_id` cancels a prior future-dated `*.priced` event. The retraction MUST name the original `event_id`, apply to the same entity and cache layer, and carry `effective_at` less than or equal to the retracted event's `effective_at`. Its `pricing_options[]` are the pricing options that remain active after the retraction. Receivers that cannot find the referenced pending event, or that receive a retraction after the referenced effective time has passed, SHOULD treat local state as uncertain and repair through the appropriate wholesale read. +A `*.priced` event with `retracts_event_id` cancels a prior future-dated `*.priced` event. The retraction MUST name the original `event_id`, apply to the same entity and cache layer, and carry `effective_at` less than or equal to the retracted event's `effective_at`. Its negotiated `canonical_pricing_options[]` or legacy `pricing_options[]` are the prices that remain active after the retraction. Receivers that cannot find the referenced pending event, or that receive a retraction after the referenced effective time has passed, SHOULD treat local state as uncertain and repair through the appropriate wholesale read. ## Registration @@ -122,6 +122,7 @@ Buyers register wholesale feed webhooks through `sync_accounts.accounts[].notifi "signal.removed", "wholesale_feed.bulk_change" ], + "product_payload_view": "canonical", "active": true } ] @@ -132,6 +133,8 @@ Buyers register wholesale feed webhooks through `sync_accounts.accounts[].notifi The buyer verifies applied state via `list_accounts.accounts[].notification_configs[]`. `sync_accounts` uses declarative replace semantics for this array: omit to leave existing subscribers unchanged; send `[]` to remove all subscribers; send a full array to replace. +`product_payload_view` negotiates the product event shape per subscriber. `canonical` is required for a `list_products` mirror; omission defaults to `legacy` so existing 3.x `get_products` registrations remain valid. The seller echoes the selected view on product webhook fires and emits exactly the matching replacement property. + Sellers MUST complete an endpoint activation challenge or equivalent proof-of-control before treating a wholesale feed subscriber as active. This prevents an authenticated buyer from registering an arbitrary third-party HTTPS URL as a high-volume webhook receiver. Delivery-time SSRF validation and connection pinning still apply to every webhook fire. ## Delivery Semantics @@ -141,9 +144,9 @@ Wholesale feed webhooks follow the persistent webhook contract: - **At least once.** Receivers dedupe retries by `idempotency_key`. - **Event correlation.** Receivers correlate logical feed changes by `notification_id`. - **No global ordering guarantee.** Sellers SHOULD emit sortable event ids, but receivers MUST tolerate out-of-order arrival. -- **Payload is usable but not binding authority.** Receivers MAY apply payloads to mirrors. Before any action that commits spend or authority, receivers MUST re-read through `get_products` / `get_signals`. -- **Repair path.** If a receiver observes a gap, stale version, failed signature, unexpected `previous_wholesale_feed_version`, or `wholesale_feed.bulk_change`, it repairs by calling `get_products` / `get_signals` with the last trusted `if_wholesale_feed_version` or by cold-bootstrapping the feed. -- **Per-principal scope filter.** Sellers MUST apply the same account/caller authorization predicate used by the corresponding wholesale read at webhook emission time. A caller that could not see a product, signal, price, or account overlay through `get_products buying_mode: "wholesale"` or `get_signals discovery_mode: "wholesale"` MUST NOT receive a webhook revealing that change. Agents unable to guarantee this per-principal filtering MUST NOT declare `wholesale_feed_webhooks.supported: true`. +- **Payload is usable but not binding authority.** Receivers MAY apply payloads to mirrors. Before any action that commits spend or authority, receivers MUST re-read through `list_products` / `get_signals`. +- **Repair path.** If a receiver observes a gap, stale version, failed signature, or unexpected `previous_wholesale_feed_version`, it repairs using the mirror's last successfully applied version as the conditional token. For uncertain state or `wholesale_feed.bulk_change`, omit the conditional token and replace the mirror from a complete `list_products` / `get_signals` read. The webhook's `wholesale_feed_version` describes post-change state and MUST NOT be used as the conditional token for a stale mirror. +- **Per-principal scope filter.** Sellers MUST apply the same account/caller authorization predicate used by the corresponding wholesale read at webhook emission time. A caller that could not see a product, signal, price, or account overlay through `list_products` or wholesale `get_signals` MUST NOT receive a webhook revealing that change. Agents unable to guarantee this per-principal filtering MUST NOT declare `wholesale_feed_webhooks.supported: true`. ### Headers @@ -155,7 +158,7 @@ Implementations that need a simple dispatch header MAY use `X-AdCP-Notification- Sellers publish two notional layers: a **public layer** (rate-card / structural / unauthenticated view) and **per-account overlays** (custom deals, account-specific rate cards). Conditional fetch and webhooks are both layer-aware. -**Versions are scope-keyed.** A `wholesale_feed_version` token describes a state for one `cache_scope` value (`"public"` or `"account"`). Consumers cache `(scope, version)` pairs and present the matching token on the next `get_products` / `get_signals` request. +**Versions are scope-keyed.** A webhook `wholesale_feed_version` token describes post-change state for one `cache_scope` value (`"public"` or `"account"`). Consumers store it only after applying that event successfully. Conditional repair sends the mirror's stored version, not a newer webhook version that the mirror has not applied. **Events carry `applies_to.scope`.** @@ -165,7 +168,7 @@ Sellers publish two notional layers: a **public layer** (rate-card / structural | `"account"` with `account_ids[]` | Affects named account overlays. | Update/invalidate only those account overlays. | | `"account"` without `account_ids` | Affects specific accounts; seller withholds the list. | Receiving the webhook means this subscriber's account scope is affected because the seller filtered delivery to authorized subscribers. Update/invalidate the receiving account overlay. | -**Cross-scope downgrade.** A seller MAY return `cache_scope: "public"` on an `if_wholesale_feed_version` request that previously had `cache_scope: "account"`. This signals "this account no longer has overrides; drop the overlay and reference the public layer." The webhook equivalent is a `*.priced` event with `applies_to: { "scope": "public" }` after a prior account-scoped event. +**Cross-scope downgrade.** A seller MAY return `cache_scope: "public"` on a conditional `list_products` or `get_signals` request that previously had `cache_scope: "account"`. This signals "this account no longer has overrides; drop the overlay and reference the public layer." The webhook equivalent is a `*.priced` event with `applies_to: { "scope": "public" }` after a prior account-scoped event. ## Capability Declaration @@ -184,22 +187,22 @@ Agents declare support in `get_adcp_capabilities`: } ``` -Agents that do not declare this stanza are presumed not to push wholesale feed changes. Consumers fall back to polling with `get_products` / `get_signals`, optionally using `if_wholesale_feed_version`. +Agents that do not declare this stanza are presumed not to push wholesale feed changes. Consumers fall back to conditional reconciliation with `list_products` / `get_signals`. -Capability consistency is part of the declaration. Agents listing any `product.*` value in `wholesale_feed_webhooks.event_types[]` MUST declare and support wholesale `get_products` (`media_buy.buying_modes` includes `"wholesale"`). Agents listing any `signal.*` value MUST declare and support wholesale `get_signals` (`signals.discovery_modes` includes `"wholesale"`). Agents listing `wholesale_feed.bulk_change` MUST have at least one of those wholesale repair paths, and each bulk-change payload's `affected_entity_type` MUST name only a feed family the agent can repair through a declared wholesale read. +Capability consistency is part of the declaration. Agents listing any `product.*` value in `wholesale_feed_webhooks.event_types[]` MUST declare `list_products` in `media_buy.lifecycle_tools` or support deprecated wholesale `get_products` throughout the 3.x compatibility window. Agents listing any `signal.*` value MUST support wholesale `get_signals`. Agents listing `wholesale_feed.bulk_change` MUST have at least one declared repair path. ## Consumer Pattern -1. **Bootstrap:** Call `get_products buying_mode: "wholesale"` and/or `get_signals discovery_mode: "wholesale"`. Store entity IDs, payloads, `wholesale_feed_version`, and `cache_scope`. +1. **Bootstrap:** Call `list_products` and/or `get_signals discovery_mode: "wholesale"`. Store entity IDs, payloads, the returned feed version, and `cache_scope`. A 3.x compatibility client may use wholesale `get_products` instead. 2. **Subscribe:** Register `sync_accounts.accounts[].notification_configs[]` entries for the relevant event types. 3. **Apply pushes:** On each valid webhook, dedupe by `idempotency_key`, correlate by `notification_id`, and apply `event.payload` to the local mirror. -4. **Repair:** On `wholesale_feed.bulk_change`, suspicious ordering, missed delivery, or before binding spend/authority, call `get_products` / `get_signals` with `if_wholesale_feed_version`. +4. **Repair:** On a detected gap, send the mirror's last applied conditional version. On `wholesale_feed.bulk_change` or otherwise uncertain state, omit it and perform a complete replacement read. ## Relationship to Other Specs -- **`get_products` wholesale mode** defines the wholesale product feed that bootstraps and repairs product mirrors. +- **`list_products`** defines the canonical wholesale product feed that bootstraps and repairs product mirrors. Deprecated `get_products` wholesale mode remains the 3.x compatibility path. - **`get_signals` wholesale mode** defines the wholesale signals feed that bootstraps and repairs signal mirrors. -- **`wholesale_feed_version` conditional fetch** is the repair probe used after missed or distrusted webhooks. +- **Conditional fetch** through `list_products.if_feed_version` or `get_signals.if_wholesale_feed_version` is the repair probe used after missed or distrusted webhooks. - **`sync_accounts.accounts[].notification_configs[]`** is the account-level registration surface. - **`sync_catalogs`** is separate: buyer-provided campaign input feeds, not seller-side wholesale product/signals feeds. diff --git a/static/schemas/source/account/sync-accounts-request.json b/static/schemas/source/account/sync-accounts-request.json index e4ac4ff226..71416c2d4c 100644 --- a/static/schemas/source/account/sync-accounts-request.json +++ b/static/schemas/source/account/sync-accounts-request.json @@ -60,7 +60,7 @@ }, "notification_configs": { "type": "array", - "description": "Account-level webhook subscriptions for notifications whose lifecycle outlives any single media buy (`creative.status_changed`, `creative.purged`, `account.status_changed`, wholesale feed change payloads, future account-anchored resource events after those event types are added to `notification-config.json`). Durable account lifecycle transitions such as later `payment_required`, `suspended`, `closed`, or recovery to `active` use `account.status_changed` on this surface; the one-shot `sync_accounts.push_notification_config` channel remains scoped to the async result of the original provisioning task. Declarative replace semantics: when this field is present, the buyer sends the full desired array and the seller replaces the account's current set with that array, keyed by account-scoped `subscriber_id`. Omit this field to leave existing subscribers unchanged; send `[]` to remove all subscribers. Re-sending an existing `subscriber_id` for the account replaces that subscriber's config rather than creating a duplicate; persisted entries whose `subscriber_id` does not appear in the sent array are removed, so the seller MUST NOT merge the new array with persisted state. Paused entries (`active: false`) use the same replacement semantics; a buyer that wants to preserve a paused subscriber MUST re-include it with `active: false`. Duplicate `subscriber_id` values within one submitted array are rejected. Permitted in both provisioning and settings-update modes. Each entry registers a URL, the event types the subscriber wants, and optional legacy auth — see [`notification-config.json`](/schemas/core/notification-config.json). The seller MUST echo applied state on the response and on `list_accounts` reads, with `authentication.credentials` omitted (write-only). Sellers MUST reject entries whose `event_types` include any type whose contract anchors at a media buy or below (today: `scheduled`, `final`, `delayed`, `adjusted`, `window_update`, `impairment`) or at the agent (today: `capabilities.changed`) as per-account validation failures with `INVALID_REQUEST` or `VALIDATION_ERROR` and `error.field` pointing at the invalid `event_types` entry — those events do not belong on this surface. Wholesale feed webhook registrations carry the actual change payload in `/schemas/core/wholesale-feed-webhook.json`; receivers use `get_products` / `get_signals` with `if_wholesale_feed_version` to repair or reconcile. Account status change registrations carry the invalidation payload in `/schemas/core/account-status-changed-webhook.json`; receivers use `list_accounts` to repair or reconcile. This is distinct from sync_catalogs, which manages buyer-provided campaign input feeds on a seller account.\n\nActivation proof: before activating a new or changed active subscriber, the seller MUST validate the URL, complete the account-level webhook proof-of-control challenge, and only then persist or expose the subscriber as `active: true`. For `account.status_changed`, sellers MUST assign `account_id` before completing proof so subsequent status transitions can identify the account and be repaired through `list_accounts`, even when external approval remains pending. A valid existing proof for the same `(account_id, subscriber_id, normalized url, authentication mode/credential binding, normalized event_types)` tuple MAY be reused; changing any element of that tuple requires fresh proof. The challenge POST itself MUST be signed with the seller's RFC 9421 webhook profile key and MUST include seller_agent_url, delivery_auth, and event_types so the receiver can verify the pending registration before echoing the challenge. New signers use `adcp_use: \"request-signing\"`; deprecated `webhook-signing` keys remain accepted during the compatibility window. Entries sent with `active: false` may skip only the outbound proof challenge while inactive; sellers MUST still enforce URL parsing, HTTPS, hostname normalization, and reserved-range rejection at write time, and those entries MUST NOT receive fires until reactivated. If proof fails or times out, the seller rejects the account entry with `action: \"failed\"`, leaves the prior notification_configs[] set unchanged, and reports `VALIDATION_ERROR` (or `INVALID_REQUEST` for malformed URLs) at the failing `notification_configs[j].url` field.\n\n**Cap rationale:** `maxItems: 16` is a practical fan-out cap (governance + buyer ingestion + audit bus + dx team + a few partner hooks). The cap exists to prevent unbounded subscriber arrays in storage and to bound the seller's per-event fan-out work. Sellers that hit the cap with legitimate subscribers should surface this on the protocol roadmap rather than work around it.", + "description": "Account-level webhook subscriptions for notifications whose lifecycle outlives any single media buy (`creative.status_changed`, `creative.purged`, `account.status_changed`, wholesale feed change payloads, future account-anchored resource events after those event types are added to `notification-config.json`). Durable account lifecycle transitions such as later `payment_required`, `suspended`, `closed`, or recovery to `active` use `account.status_changed` on this surface; the one-shot `sync_accounts.push_notification_config` channel remains scoped to the async result of the original provisioning task. Declarative replace semantics: when this field is present, the buyer sends the full desired array and the seller replaces the account's current set with that array, keyed by account-scoped `subscriber_id`. Omit this field to leave existing subscribers unchanged; send `[]` to remove all subscribers. Re-sending an existing `subscriber_id` for the account replaces that subscriber's config rather than creating a duplicate; persisted entries whose `subscriber_id` does not appear in the sent array are removed, so the seller MUST NOT merge the new array with persisted state. Paused entries (`active: false`) use the same replacement semantics; a buyer that wants to preserve a paused subscriber MUST re-include it with `active: false`. Duplicate `subscriber_id` values within one submitted array are rejected. Permitted in both provisioning and settings-update modes. Each entry registers a URL, the event types the subscriber wants, and optional legacy auth — see [`notification-config.json`](/schemas/core/notification-config.json). The seller MUST echo applied state on the response and on `list_accounts` reads, with `authentication.credentials` omitted (write-only). Sellers MUST reject entries whose `event_types` include any type whose contract anchors at a media buy or below (today: `scheduled`, `final`, `delayed`, `adjusted`, `window_update`, `impairment`) or at the agent (today: `capabilities.changed`) as per-account validation failures with `INVALID_REQUEST` or `VALIDATION_ERROR` and `error.field` pointing at the invalid `event_types` entry — those events do not belong on this surface. Wholesale feed webhook registrations carry the actual change payload in `/schemas/core/wholesale-feed-webhook.json`; canonical product subscribers repair through `list_products(if_feed_version)`, legacy product subscribers through `get_products(if_wholesale_feed_version)`, and signal subscribers through `get_signals(if_wholesale_feed_version)`. Account status change registrations carry the invalidation payload in `/schemas/core/account-status-changed-webhook.json`; receivers use `list_accounts` to repair or reconcile. This is distinct from sync_catalogs, which manages buyer-provided campaign input feeds on a seller account.\n\nActivation proof: before activating a new or changed active subscriber, the seller MUST validate the URL, complete the account-level webhook proof-of-control challenge, and only then persist or expose the subscriber as `active: true`. For `account.status_changed`, sellers MUST assign `account_id` before completing proof so subsequent status transitions can identify the account and be repaired through `list_accounts`, even when external approval remains pending. A valid existing proof for the same `(account_id, subscriber_id, normalized url, authentication mode/credential binding, normalized event_types)` tuple MAY be reused; changing any element of that tuple requires fresh proof. The challenge POST itself MUST be signed with the seller's RFC 9421 webhook profile key and MUST include seller_agent_url, delivery_auth, and event_types so the receiver can verify the pending registration before echoing the challenge. New signers use `adcp_use: \"request-signing\"`; deprecated `webhook-signing` keys remain accepted during the compatibility window. Entries sent with `active: false` may skip only the outbound proof challenge while inactive; sellers MUST still enforce URL parsing, HTTPS, hostname normalization, and reserved-range rejection at write time, and those entries MUST NOT receive fires until reactivated. If proof fails or times out, the seller rejects the account entry with `action: \"failed\"`, leaves the prior notification_configs[] set unchanged, and reports `VALIDATION_ERROR` (or `INVALID_REQUEST` for malformed URLs) at the failing `notification_configs[j].url` field.\n\n**Cap rationale:** `maxItems: 16` is a practical fan-out cap (governance + buyer ingestion + audit bus + dx team + a few partner hooks). The cap exists to prevent unbounded subscriber arrays in storage and to bound the seller's per-event fan-out work. Sellers that hit the cap with legitimate subscribers should surface this on the protocol roadmap rather than work around it.", "items": { "allOf": [ { diff --git a/static/schemas/source/compliance/comply-test-controller-request.json b/static/schemas/source/compliance/comply-test-controller-request.json index 4e49527117..0a50d17284 100644 --- a/static/schemas/source/compliance/comply-test-controller-request.json +++ b/static/schemas/source/compliance/comply-test-controller-request.json @@ -1002,8 +1002,8 @@ "description": "Human-readable identifier for the upstream dependency to force unavailable (e.g., 'inventory-service', 'creative-agent'). Used by force_upstream_unavailable. When omitted, the seller marks its default upstream for the specified tool as unavailable. Sellers MUST include the same name in STALE_RESPONSE error.details.upstream.name on the affected response." }, "result": { - "$ref": "/schemas/core/async-response-data.json", - "description": "Completion payload to record against the task. Used by force_task_completion. Validates against the async-response-data union — for create_media_buy this is a CreateMediaBuyResponse with media_buy_id and packages. The seller MUST deliver this verbatim to the buyer's push_notification_config.url (the canonical 3.0 path for completion payload delivery), with all caller-supplied fields preserved (sellers MAY augment with seller-controlled fields like created_at or dsp_* IDs but MUST NOT overwrite caller-supplied values). A typed projection on the polling response is tracked for 3.1 (#3123). Sellers MUST emit INVALID_PARAMS if the payload does not validate against the response branch for the task's original method, and MAY reject payloads exceeding 256 KB with INVALID_PARAMS." + "$ref": "/schemas/compliance/task-completion-data.json", + "description": "Completion payload to record against the task. force_task_completion supports the bounded get_products, get_signals, and create_media_buy completion union; polling and production SDKs resolve all task results through the originating task's manifest response mapping instead of embedding a global result union. The seller MUST deliver this verbatim to the buyer's push_notification_config.url, preserving caller-supplied fields. Sellers MUST emit INVALID_PARAMS when the payload does not match the original task response branch and MAY reject payloads exceeding 256 KB." } }, "additionalProperties": true @@ -1094,7 +1094,9 @@ "task_id": "task_async_signed_io_q2", "result": { "media_buy_id": "mb_async_signed_io_q2", - "status": "active", + "media_buy_status": "active", + "confirmed_at": "2026-08-12T12:00:00Z", + "revision": 1, "packages": [ { "package_id": "pkg-0", diff --git a/static/schemas/source/compliance/task-completion-data.json b/static/schemas/source/compliance/task-completion-data.json new file mode 100644 index 0000000000..6646d7261b --- /dev/null +++ b/static/schemas/source/compliance/task-completion-data.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/compliance/task-completion-data.json", + "title": "Compliance Task Completion Data", + "description": "Bounded completion union for the legacy async tasks supported by force_task_completion. Polling and production task-result resolution use the originating task's manifest response schema rather than embedding the global async union.", + "anyOf": [ + { + "title": "GetProductsCompletion", + "$ref": "/schemas/media-buy/get-products-response.json" + }, + { + "title": "GetSignalsCompletion", + "$ref": "/schemas/signals/get-signals-response.json" + }, + { + "title": "CreateMediaBuyCompletion", + "$ref": "/schemas/media-buy/create-media-buy-response.json#/oneOf/0" + } + ] +} diff --git a/static/schemas/source/core/account-authorization.json b/static/schemas/source/core/account-authorization.json index 0cff0698fb..73f0e2113d 100644 --- a/static/schemas/source/core/account-authorization.json +++ b/static/schemas/source/core/account-authorization.json @@ -7,7 +7,7 @@ "properties": { "allowed_tasks": { "type": "array", - "description": "Canonical snake_case task names the caller may invoke against this account (e.g., get_media_buys, update_media_buy, create_media_buy, sync_creatives). Absence of a task from this list MUST be interpreted as 'not permitted' — invoking an absent task MUST return SCOPE_INSUFFICIENT. This list reflects the caller's grant, not the seller's universal capability surface. AdCP 3.2 compact product tools are authorized by their own names; a get_products grant does not silently authorize a stateful proposal task.", + "description": "Canonical snake_case task names the caller may invoke against this account (for example get_media_buys, buy_products, accept_proposal, control_media_buy, or sync_creatives). Absence of a task from this list means not permitted and returns SCOPE_INSUFFICIENT. Compact 3.2 tools are authorized by their own names; grants for deprecated get_products/create_media_buy/update_media_buy do not silently transfer across aliases.", "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" diff --git a/static/schemas/source/core/async-response-data.json b/static/schemas/source/core/async-response-data.json index 6026f2c092..4496700c89 100644 --- a/static/schemas/source/core/async-response-data.json +++ b/static/schemas/source/core/async-response-data.json @@ -44,6 +44,31 @@ "description": "Terminal response for decline_proposals", "$ref": "/schemas/media-buy/decline-proposals-response.json" }, + { + "title": "MediaBuyCommitmentResponse", + "description": "Shared terminal response for buy_products and accept_proposal; originating task identity is carried by the task envelope and manifest resolver.", + "$ref": "/schemas/media-buy/media-buy-commitment-response.json" + }, + { + "title": "ControlMediaBuyResponse", + "description": "Terminal response for control_media_buy", + "$ref": "/schemas/media-buy/control-media-buy-response.json" + }, + { + "title": "CompactTaskSubmitted", + "description": "Submitted envelope for any compact lifecycle task; the originating tool is resolved from the task envelope.", + "$ref": "/schemas/core/compact-task-submitted.json" + }, + { + "title": "CompactTaskWorking", + "description": "Working progress for any compact lifecycle task.", + "$ref": "/schemas/core/compact-task-working.json" + }, + { + "title": "CompactTaskInputRequired", + "description": "Input-required payload for any compact lifecycle task.", + "$ref": "/schemas/core/compact-task-input-required.json" + }, { "title": "GetSignalsResponse", "description": "Response for completed or failed get_signals", diff --git a/static/schemas/source/core/canonical-account-ref.json b/static/schemas/source/core/canonical-account-ref.json new file mode 100644 index 0000000000..dfefc08b12 --- /dev/null +++ b/static/schemas/source/core/canonical-account-ref.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-account-ref.json", + "title": "Canonical Account Reference", + "description": "Compact account identity for canonical 3.2 tools. Uses either a seller-assigned account_id or the buyer-declared natural key without BrandRef inline overrides.", + "type": "object", + "anyOf": [ + { + "properties": { + "account_id": { + "type": "string", + "minLength": 1, + "x-entity": "account" + } + }, + "required": ["account_id"], + "additionalProperties": false + }, + { + "properties": { + "brand": { + "$ref": "/schemas/core/brand-key.json" + }, + "operator": { + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$", + "x-entity": "operator" + }, + "sandbox": { + "type": "boolean", + "default": false + } + }, + "required": ["brand", "operator"], + "additionalProperties": false + } + ] +} diff --git a/static/schemas/source/core/canonical-audience-evidence-selection.json b/static/schemas/source/core/canonical-audience-evidence-selection.json new file mode 100644 index 0000000000..b06e0e87da --- /dev/null +++ b/static/schemas/source/core/canonical-audience-evidence-selection.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-audience-evidence-selection.json", + "title": "Canonical Audience Evidence Selection", + "description": "Compact decision readback pinning the evidence snapshot that affected recommendation or eligibility.", + "type": "object", + "properties": { + "evidence_id": { "type": "string", "minLength": 1, "x-entity": "audience_evidence" }, + "snapshot_id": { "type": "string", "minLength": 1, "x-entity": "audience_evidence_snapshot" }, + "version": { "type": "string", "minLength": 1 }, + "content_digest": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "decision_use": { "type": "string", "enum": ["recommendation", "eligibility", "package_construction"] }, + "evidence": { "$ref": "/schemas/core/canonical-audience-evidence.json" }, + "verified_attestation_digests": { "type": "array", "items": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, "minItems": 1, "uniqueItems": true }, + "ext": { "$ref": "/schemas/core/ext.json" } + }, + "required": ["evidence_id", "snapshot_id", "version", "content_digest", "decision_use"], + "additionalProperties": false +} diff --git a/static/schemas/source/core/canonical-audience-evidence.json b/static/schemas/source/core/canonical-audience-evidence.json new file mode 100644 index 0000000000..11ec3159b9 --- /dev/null +++ b/static/schemas/source/core/canonical-audience-evidence.json @@ -0,0 +1,53 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-audience-evidence.json", + "title": "Canonical Audience Evidence", + "description": "Compact immutable audience-evidence snapshot for product discovery. Provider identity is a BrandKey; credential and brand-asset graphs are resolved separately.", + "type": "object", + "properties": { + "evidence_id": { "type": "string", "minLength": 1, "x-entity": "audience_evidence" }, + "snapshot_id": { "type": "string", "minLength": 1, "x-entity": "audience_evidence_snapshot" }, + "version": { "type": "string", "minLength": 1 }, + "content_digest": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "audience": { "$ref": "/schemas/core/audience-characteristic.json" }, + "relationship": { "type": "string", "enum": ["composition", "index", "reach_estimate"] }, + "value": { "type": "number", "minimum": 0 }, + "unit": { "type": "string", "enum": ["fraction", "ratio", "count"] }, + "baseline": { + "type": "object", + "properties": { + "system": { "type": "string", "format": "uri", "pattern": "^https://" }, + "population_id": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "description": { "type": "string" } + }, + "required": ["system", "population_id", "version"], + "additionalProperties": false + }, + "evidence_type": { "type": "string", "enum": ["measured", "forecast", "seller_declared"] }, + "methodology": { "$ref": "/schemas/enums/audience-evidence-methodology.json" }, + "subject_type": { "$ref": "/schemas/enums/audience-subject-type.json" }, + "resolution_method": { "$ref": "/schemas/enums/audience-resolution-method.json" }, + "provider": { "$ref": "/schemas/core/brand-key.json" }, + "measurement_window": { "$ref": "/schemas/core/date-range.json" }, + "sample_size": { "type": "integer", "minimum": 1 }, + "confidence": { "type": "number", "minimum": 0, "maximum": 1 }, + "last_updated": { "type": "string", "format": "date-time" }, + "methodology_url": { "type": "string", "format": "uri", "pattern": "^https://" }, + "attestation_digests": { + "type": "array", + "items": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "minItems": 1, + "uniqueItems": true, + "description": "Portable-attestation reference digests available through the evidence provider; credential bodies are not inlined into product discovery." + }, + "ext": { "$ref": "/schemas/core/ext.json" } + }, + "required": ["evidence_id", "snapshot_id", "version", "content_digest", "audience", "relationship", "value", "unit", "baseline", "evidence_type", "methodology", "subject_type", "provider", "measurement_window", "last_updated"], + "allOf": [ + { "if": { "properties": { "relationship": { "const": "composition" } }, "required": ["relationship"] }, "then": { "properties": { "unit": { "const": "fraction" }, "value": { "maximum": 1 } } } }, + { "if": { "properties": { "relationship": { "const": "index" } }, "required": ["relationship"] }, "then": { "properties": { "unit": { "const": "ratio" } } } }, + { "if": { "properties": { "relationship": { "const": "reach_estimate" } }, "required": ["relationship"] }, "then": { "properties": { "unit": { "const": "count" }, "value": { "type": "integer" } } } } + ], + "additionalProperties": false +} diff --git a/static/schemas/source/core/canonical-budget-allocation.json b/static/schemas/source/core/canonical-budget-allocation.json new file mode 100644 index 0000000000..333a815b3f --- /dev/null +++ b/static/schemas/source/core/canonical-budget-allocation.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-budget-allocation.json", + "title": "Canonical Budget Allocation", + "description": "Canonical fixed or seller-optimized allocation using only canonical optimization goals.", + "discriminator": { "propertyName": "mode" }, + "oneOf": [ + { + "type": "object", + "properties": { + "mode": { "type": "string", "const": "fixed" } + }, + "required": ["mode"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { "type": "string", "const": "seller_optimized" }, + "optimization_goals": { + "type": "array", + "items": { "$ref": "/schemas/core/canonical-optimization-goal.json" }, + "minItems": 1 + } + }, + "required": ["mode", "optimization_goals"], + "additionalProperties": false + } + ] +} diff --git a/static/schemas/source/core/canonical-delivery-forecast.json b/static/schemas/source/core/canonical-delivery-forecast.json new file mode 100644 index 0000000000..52cfe301d8 --- /dev/null +++ b/static/schemas/source/core/canonical-delivery-forecast.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-delivery-forecast.json", + "title": "Canonical Delivery Forecast", + "description": "Compact product forecast without legacy BrandRef or creative dependencies.", + "type": "object", + "properties": { + "points": { + "type": "array", + "items": { "$ref": "/schemas/core/canonical-forecast-point.json" }, + "minItems": 1 + }, + "forecast_range_unit": { "$ref": "/schemas/enums/forecast-range-unit.json" }, + "method": { "$ref": "/schemas/enums/forecast-method.json" }, + "currency": { "type": "string", "pattern": "^[A-Z]{3}$" }, + "demographic_system": { "$ref": "/schemas/enums/demographic-system.json" }, + "demographic": { "type": "string" }, + "measurement_source": { "type": "string", "maxLength": 64, "pattern": "^[a-z0-9_]+$" }, + "reach_unit": { "$ref": "/schemas/enums/reach-unit.json" }, + "generated_at": { "type": "string", "format": "date-time" }, + "valid_until": { "type": "string", "format": "date-time" }, + "ext": { "$ref": "/schemas/core/ext.json" } + }, + "required": ["points", "method", "currency"], + "additionalProperties": false +} diff --git a/static/schemas/source/core/canonical-forecast-point.json b/static/schemas/source/core/canonical-forecast-point.json new file mode 100644 index 0000000000..d16d3a4c9f --- /dev/null +++ b/static/schemas/source/core/canonical-forecast-point.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-forecast-point.json", + "title": "Canonical Forecast Point", + "description": "Compact forecast row with numeric ranges and identity-only measurement-vendor references.", + "type": "object", + "properties": { + "label": { "type": "string", "maxLength": 128 }, + "budget": { "type": "number", "minimum": 0 }, + "product_id": { "type": "string", "x-entity": "product" }, + "dimensions": { "$ref": "/schemas/core/forecast-point-dimensions.json" }, + "metrics": { + "type": "object", + "additionalProperties": { "$ref": "/schemas/core/forecast-range.json" } + }, + "viewability": { + "type": "object", + "properties": { + "vendor": { "$ref": "/schemas/core/brand-key.json" }, + "measurable_impressions": { "$ref": "/schemas/core/forecast-range.json" }, + "viewable_impressions": { "$ref": "/schemas/core/forecast-range.json" }, + "viewable_rate": { "$ref": "/schemas/core/forecast-range.json" }, + "viewed_seconds": { "$ref": "/schemas/core/forecast-range.json" }, + "standard": { "$ref": "/schemas/enums/viewability-standard.json" } + }, + "additionalProperties": false + }, + "vendor_metric_values": { + "type": "array", + "items": { "$ref": "/schemas/core/canonical-forecast-vendor-metric-value.json" } + } + }, + "required": ["metrics"], + "additionalProperties": false +} diff --git a/static/schemas/source/core/canonical-forecast-vendor-metric-value.json b/static/schemas/source/core/canonical-forecast-vendor-metric-value.json new file mode 100644 index 0000000000..80192d1f84 --- /dev/null +++ b/static/schemas/source/core/canonical-forecast-vendor-metric-value.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-forecast-vendor-metric-value.json", + "title": "Canonical Forecast Vendor Metric Value", + "description": "Compact forecasted vendor metric with a BrandKey vendor pointer and no embedded brand assets.", + "type": "object", + "properties": { + "vendor": { "$ref": "/schemas/core/brand-key.json" }, + "metric_id": { "$ref": "/schemas/core/vendor-metric-id.json" }, + "value": { "$ref": "/schemas/core/forecast-range.json" }, + "unit": { "type": "string" }, + "measurable_impressions": { "$ref": "/schemas/core/forecast-range.json" }, + "breakdown": { "type": "object", "additionalProperties": true } + }, + "required": ["vendor", "metric_id", "value"], + "additionalProperties": false +} diff --git a/static/schemas/source/core/canonical-format-option.json b/static/schemas/source/core/canonical-format-option.json new file mode 100644 index 0000000000..ef35835d65 --- /dev/null +++ b/static/schemas/source/core/canonical-format-option.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-format-option.json", + "title": "Canonical Format Option", + "description": "Compact canonical creative-format declaration. Legacy named-format links are intentionally absent; params are validated against the canonical schema selected by format_kind without inlining every format union into product discovery.", + "type": "object", + "properties": { + "format_option_id": { "type": "string", "minLength": 1 }, + "publisher_domain": { "type": "string", "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" }, + "display_name": { "type": "string", "minLength": 1 }, + "sample_render_url": { "type": "string", "format": "uri", "pattern": "^https://" }, + "applies_to_channels": { + "type": "array", + "items": { "$ref": "/schemas/enums/channels.json" }, + "minItems": 1, + "uniqueItems": true + }, + "seller_preference": { "type": "string", "enum": ["preferred", "accepted", "discouraged"] }, + "locale_policy": { "$ref": "/schemas/core/creative-locale-policy.json" }, + "canonical_formats_only": { "type": "boolean", "default": false }, + "experimental": { "type": "boolean", "default": false }, + "format_kind": { + "type": "string", + "enum": ["image", "html5", "display_tag", "image_carousel", "video_hosted", "video_vast", "audio_hosted", "audio_daast", "sponsored_placement", "native_in_feed", "responsive_creative", "agent_placement", "custom"] + }, + "params": { "type": "object", "additionalProperties": true }, + "format_shape": { "type": "string", "minLength": 1 }, + "format_schema": { "$ref": "/schemas/core/platform-extension-ref.json" } + }, + "required": ["format_kind", "params"], + "allOf": [ + { + "if": { "properties": { "format_kind": { "const": "custom" } }, "required": ["format_kind"] }, + "then": { "required": ["format_shape", "format_schema", "canonical_formats_only"] }, + "else": { "not": { "anyOf": [{ "required": ["format_shape"] }, { "required": ["format_schema"] }] } } + } + ], + "x-adcp-validation": { + "verifier_constraints": { + "canonical_params": { + "non_custom": "validate params against /schemas/formats/canonical/{format_kind}.json", + "custom": "validate params against fetched format_schema uri and digest", + "on_violation": "reject_product" + } + } + }, + "additionalProperties": false +} diff --git a/static/schemas/source/core/canonical-measurement-terms.json b/static/schemas/source/core/canonical-measurement-terms.json new file mode 100644 index 0000000000..0761537071 --- /dev/null +++ b/static/schemas/source/core/canonical-measurement-terms.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-measurement-terms.json", + "title": "Canonical Measurement Terms", + "description": "Compact billing-measurement and makegood terms. Vendor identity is a resolvable BrandKey; brand assets are never embedded.", + "type": "object", + "properties": { + "billing_measurement": { + "type": "object", + "properties": { + "vendor": { "$ref": "/schemas/core/brand-key.json" }, + "max_variance_percent": { "type": "number", "minimum": 0, "exclusiveMaximum": 100 }, + "measurement_window": { "type": "string", "minLength": 1 }, + "finalization_deadline_hours": { "type": "integer", "minimum": 0 } + }, + "required": ["vendor"], + "additionalProperties": false + }, + "makegood_policy": { + "type": "object", + "properties": { + "available_remedies": { + "type": "array", + "items": { "$ref": "/schemas/enums/makegood-remedy.json" }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": ["available_remedies"], + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/static/schemas/source/core/canonical-media-buy-action.json b/static/schemas/source/core/canonical-media-buy-action.json new file mode 100644 index 0000000000..5f22e9e8c4 --- /dev/null +++ b/static/schemas/source/core/canonical-media-buy-action.json @@ -0,0 +1,54 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-media-buy-action.json", + "title": "Canonical Media Buy Action", + "description": "A currently available MediaBuy action with the compact-lifecycle task an SDK calls to exercise it. Deprecated coarse action values are absent.", + "type": "object", + "discriminator": { + "propertyName": "task" + }, + "oneOf": [ + { + "properties": { + "task": { "type": "string", "const": "control_media_buy" }, + "action": { + "type": "string", + "enum": ["pause", "resume", "cancel", "increase_budget", "decrease_budget", "reallocate_budget", "update_budget_allocation", "update_targeting", "update_pacing", "update_bidding", "update_frequency_caps", "update_catalog_assignments", "update_keywords", "update_optimization_goals", "update_impression_goal", "update_spend_target", "update_reporting_webhook", "remove_packages"] + }, + "mode": { "$ref": "/schemas/enums/canonical-media-buy-action-mode.json" }, + "sla": { "$ref": "/schemas/core/sla-window.json" }, + "terms_ref": { "type": "string" } + }, + "required": ["task", "action", "mode"], + "additionalProperties": false + }, + { + "properties": { + "task": { "type": "string", "const": "refine_proposals" }, + "action": { + "type": "string", + "enum": ["cancel", "extend_flight", "shorten_flight", "update_flight_dates", "increase_budget", "decrease_budget", "reallocate_budget", "update_budget_allocation", "update_targeting", "update_pacing", "update_bidding", "update_frequency_caps", "add_packages", "remove_packages"] + }, + "mode": { "$ref": "/schemas/enums/canonical-media-buy-action-mode.json" }, + "sla": { "$ref": "/schemas/core/sla-window.json" }, + "terms_ref": { "type": "string" } + }, + "required": ["task", "action", "mode"], + "additionalProperties": false + }, + { + "properties": { + "task": { "type": "string", "const": "sync_creatives" }, + "action": { + "type": "string", + "enum": ["replace_creative", "update_creative_assignments", "remove_creative"] + }, + "mode": { "$ref": "/schemas/enums/canonical-media-buy-action-mode.json" }, + "sla": { "$ref": "/schemas/core/sla-window.json" }, + "terms_ref": { "type": "string" } + }, + "required": ["task", "action", "mode"], + "additionalProperties": false + } + ] +} diff --git a/static/schemas/source/core/canonical-media-buy-features.json b/static/schemas/source/core/canonical-media-buy-features.json new file mode 100644 index 0000000000..a143049f6e --- /dev/null +++ b/static/schemas/source/core/canonical-media-buy-features.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-media-buy-features.json", + "title": "Canonical Media Buy Features", + "description": "Compact seller features relevant to canonical product purchase. Legacy inline-creative capabilities are absent.", + "type": "object", + "properties": { + "property_filtering": { "type": "boolean" }, + "catalog_management": { "type": "boolean" }, + "reporting_commitment_snapshots": { + "type": "boolean", + "description": "Seller preserves the accepted per-purchase reporting contract and exposes it on proposal and MediaBuy readback." + }, + "seller_optimized_budget": { "type": "boolean" }, + "bidding_policy": { "$ref": "/schemas/core/bidding-policy-capability.json" } + }, + "additionalProperties": { "type": "boolean" } +} diff --git a/static/schemas/source/core/canonical-metric-qualifier.json b/static/schemas/source/core/canonical-metric-qualifier.json new file mode 100644 index 0000000000..dbc445df5f --- /dev/null +++ b/static/schemas/source/core/canonical-metric-qualifier.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-metric-qualifier.json", + "title": "Canonical Metric Qualifier", + "description": "Compact qualifier set that distinguishes incompatible reporting definitions for the same metric ID.", + "type": "object", + "properties": { + "viewability_standard": { "$ref": "/schemas/enums/viewability-standard.json" }, + "completion_source": { "$ref": "/schemas/enums/completion-source.json" }, + "attribution_methodology": { "$ref": "/schemas/enums/attribution-methodology.json" }, + "attribution_window": { "$ref": "/schemas/core/duration.json" }, + "lift_dimension": { "$ref": "/schemas/enums/lift-dimension.json" } + }, + "additionalProperties": false +} diff --git a/static/schemas/source/core/canonical-optimization-goal.json b/static/schemas/source/core/canonical-optimization-goal.json new file mode 100644 index 0000000000..3579f24498 --- /dev/null +++ b/static/schemas/source/core/canonical-optimization-goal.json @@ -0,0 +1,98 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-optimization-goal.json", + "title": "Canonical Optimization Goal", + "description": "Canonical 3.2 optimization objective. Monetary execution policy belongs in BiddingPolicy; legacy monetary targets and unbound attention metrics are excluded.", + "discriminator": { "propertyName": "kind" }, + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "metric" }, + "metric": { + "type": "string", + "enum": ["clicks", "views", "completed_views", "viewed_seconds", "engagements", "follows", "saves", "profile_visits", "reach"] + }, + "reach_unit": { "$ref": "/schemas/enums/reach-unit.json" }, + "target_frequency": { + "type": "object", + "properties": { + "min": { "type": "integer", "minimum": 1 }, + "max": { "type": "integer", "minimum": 1 }, + "window": { "$ref": "/schemas/core/duration.json" } + }, + "required": ["window"], + "anyOf": [{ "required": ["min"] }, { "required": ["max"] }], + "additionalProperties": false + }, + "view_duration_seconds": { "type": "number", "exclusiveMinimum": 0 }, + "target": { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "threshold_rate" }, + "value": { "type": "number", "exclusiveMinimum": 0 } + }, + "required": ["kind", "value"], + "additionalProperties": false + }, + "priority": { "type": "integer", "minimum": 1 } + }, + "required": ["kind", "metric"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "event" }, + "event_sources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "event_source_id": { "type": "string", "minLength": 1, "x-entity": "event_source" }, + "event_type": { "$ref": "/schemas/enums/event-type.json" }, + "custom_event_name": { "type": "string", "minLength": 1 }, + "value_field": { "type": "string", "minLength": 1 }, + "value_factor": { "type": "number", "default": 1 } + }, + "required": ["event_source_id", "event_type"], + "additionalProperties": false + }, + "minItems": 1 + }, + "target": { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "maximize_value" } + }, + "required": ["kind"], + "additionalProperties": false + }, + "attribution_window": { "$ref": "/schemas/core/attribution-window.json" }, + "priority": { "type": "integer", "minimum": 1 } + }, + "required": ["kind", "event_sources"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "vendor_metric" }, + "vendor": { "$ref": "/schemas/core/brand-key.json" }, + "metric_id": { "$ref": "/schemas/core/vendor-metric-id.json" }, + "target": { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "threshold_rate" }, + "value": { "type": "number", "exclusiveMinimum": 0 } + }, + "required": ["kind", "value"], + "additionalProperties": false + }, + "priority": { "type": "integer", "minimum": 1 } + }, + "required": ["kind", "vendor", "metric_id"], + "additionalProperties": false + } + ] +} diff --git a/static/schemas/source/core/canonical-performance-standard.json b/static/schemas/source/core/canonical-performance-standard.json new file mode 100644 index 0000000000..58f630507b --- /dev/null +++ b/static/schemas/source/core/canonical-performance-standard.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-performance-standard.json", + "title": "Canonical Performance Standard", + "description": "Compact committed metric threshold with resolvable vendor identity and no creative or brand-asset graph.", + "type": "object", + "properties": { + "metric": { "$ref": "/schemas/enums/performance-standard-metric.json" }, + "threshold": { "type": "number", "minimum": 0, "maximum": 1 }, + "standard": { "$ref": "/schemas/enums/viewability-standard.json" }, + "vendor": { "$ref": "/schemas/core/brand-key.json" } + }, + "required": ["metric", "threshold", "vendor"], + "additionalProperties": false +} diff --git a/static/schemas/source/core/canonical-placement.json b/static/schemas/source/core/canonical-placement.json new file mode 100644 index 0000000000..3ccfd0fe98 --- /dev/null +++ b/static/schemas/source/core/canonical-placement.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-placement.json", + "title": "Canonical Product Placement", + "description": "Compact product placement with canonical format narrowing only.", + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["publisher_ref", "seller_inline"] }, + "placement_id": { "type": "string", "minLength": 1, "x-entity": "placement" }, + "publisher_domain": { "type": "string", "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" }, + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "mode": { "type": "string", "enum": ["targetable", "included"] }, + "tags": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "format_options": { + "type": "array", + "items": { "$ref": "/schemas/core/canonical-format-option.json" }, + "minItems": 1 + }, + "video_placement_types": { "type": "array", "items": { "$ref": "/schemas/enums/video-placement-type.json" }, "minItems": 1, "uniqueItems": true }, + "audio_distribution_types": { "type": "array", "items": { "$ref": "/schemas/enums/audio-distribution-type.json" }, "minItems": 1, "uniqueItems": true }, + "sponsored_placement_types": { "type": "array", "items": { "$ref": "/schemas/enums/sponsored-placement-type.json" }, "minItems": 1, "uniqueItems": true }, + "social_placement_surfaces": { "type": "array", "items": { "$ref": "/schemas/enums/social-placement-surface.json" }, "minItems": 1, "uniqueItems": true } + }, + "required": ["kind", "placement_id", "mode"], + "allOf": [ + { + "if": { "properties": { "kind": { "const": "publisher_ref" } }, "required": ["kind"] }, + "then": { "required": ["publisher_domain"] }, + "else": { "required": ["name"] } + } + ], + "additionalProperties": false +} diff --git a/static/schemas/source/core/canonical-pricing-option.json b/static/schemas/source/core/canonical-pricing-option.json new file mode 100644 index 0000000000..951c9e2f2d --- /dev/null +++ b/static/schemas/source/core/canonical-pricing-option.json @@ -0,0 +1,52 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-pricing-option.json", + "title": "Canonical Pricing Option", + "description": "Self-contained selected pricing terms for compact product offers and accepted commercial snapshots. Deprecated execution hints such as max_bid are absent; bidding intent lives in BiddingPolicy.", + "type": "object", + "properties": { + "pricing_option_id": { "type": "string", "minLength": 1, "x-entity": "product_pricing_option" }, + "pricing_model": { + "type": "string", + "enum": ["cpm", "vcpm", "cpc", "cpcv", "cpv", "cpp", "cpa", "revenue_share", "flat_rate", "time"] + }, + "currency": { "type": "string", "pattern": "^[A-Z]{3}$" }, + "fixed_price": { "type": "number", "minimum": 0 }, + "floor_price": { "type": "number", "minimum": 0 }, + "price_guidance": { "$ref": "/schemas/pricing-options/price-guidance.json" }, + "min_spend_per_package": { "type": "number", "minimum": 0 }, + "price_breakdown": { "$ref": "/schemas/pricing-options/price-breakdown.json" }, + "eligible_adjustments": { + "type": "array", + "items": { "$ref": "/schemas/enums/adjustment-kind.json" }, + "uniqueItems": true + }, + "parameters": { "type": "object", "additionalProperties": true }, + "event_type": { "$ref": "/schemas/enums/event-type.json" }, + "custom_event_name": { "type": "string", "minLength": 1 }, + "event_source_id": { "type": "string", "minLength": 1, "x-entity": "event_source" }, + "commission_rate": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, + "commission_basis_description": { "type": "string", "minLength": 1, "maxLength": 1000 } + }, + "required": ["pricing_option_id", "pricing_model", "currency"], + "allOf": [ + { "not": { "required": ["fixed_price", "floor_price"] } }, + { + "if": { "properties": { "pricing_model": { "const": "cpa" } }, "required": ["pricing_model"] }, + "then": { "required": ["event_type", "fixed_price"] } + }, + { + "if": { "properties": { "pricing_model": { "const": "revenue_share" } }, "required": ["pricing_model"] }, + "then": { "required": ["event_type", "event_source_id", "commission_rate", "commission_basis_description"] } + }, + { + "if": { "properties": { "pricing_model": { "enum": ["cpv", "cpp", "time"] } }, "required": ["pricing_model"] }, + "then": { "required": ["parameters"] } + }, + { + "if": { "properties": { "event_type": { "const": "custom" } }, "required": ["event_type"] }, + "then": { "required": ["custom_event_name"] } + } + ], + "additionalProperties": false +} diff --git a/static/schemas/source/core/canonical-product-action.json b/static/schemas/source/core/canonical-product-action.json new file mode 100644 index 0000000000..1cd4b3862d --- /dev/null +++ b/static/schemas/source/core/canonical-product-action.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-product-action.json", + "title": "Canonical Product Action", + "description": "Fine-grained action template advertised on a compact product. The resulting MediaBuy exposes the authoritative routed task in available_actions.", + "type": "object", + "properties": { + "action": { "$ref": "/schemas/enums/canonical-media-buy-action.json" }, + "modes": { "type": "array", "items": { "$ref": "/schemas/enums/canonical-media-buy-action-mode.json" }, "minItems": 1, "uniqueItems": true }, + "allowed_statuses": { "type": "array", "items": { "$ref": "/schemas/enums/media-buy-status.json" }, "minItems": 1, "uniqueItems": true }, + "sla": { "$ref": "/schemas/core/sla-window.json" }, + "terms_ref": { "type": "string" } + }, + "required": ["action", "modes"], + "additionalProperties": false +} diff --git a/static/schemas/source/core/canonical-product.json b/static/schemas/source/core/canonical-product.json index 1b3897a310..d8084ebade 100644 --- a/static/schemas/source/core/canonical-product.json +++ b/static/schemas/source/core/canonical-product.json @@ -2,36 +2,80 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/canonical-product.json", "title": "Canonical Product", - "description": "Canonical-only Product view returned by the AdCP 3.2 split product and proposal tools. It requires format_options and forbids legacy named-format identifiers at the product, placement, and format-option levels. The get_products compatibility facade remains the only product-discovery surface that may emit those legacy creative declarations.", - "allOf": [ - { "$ref": "/schemas/core/product.json" }, - { + "description": "Standalone compact Product view for the AdCP 3.2 lifecycle. product_id and name are the only always-returned fields; requested detail fields are optional. Legacy named formats, coarse MediaBuy actions, and the legacy Product inheritance graph are absent.", + "type": "object", + "properties": { + "product_id": { "type": "string", "minLength": 1, "x-entity": "product" }, + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "publisher_properties": { + "type": "array", + "items": { + "allOf": [ + { "$ref": "/schemas/core/publisher-property-selector.json" }, + { "not": { "required": ["publisher_domains"] } } + ] + }, + "minItems": 1 + }, + "channels": { "type": "array", "items": { "$ref": "/schemas/enums/channels.json" }, "uniqueItems": true }, + "video_placement_types": { "type": "array", "items": { "$ref": "/schemas/enums/video-placement-type.json" }, "minItems": 1, "uniqueItems": true }, + "audio_distribution_types": { "type": "array", "items": { "$ref": "/schemas/enums/audio-distribution-type.json" }, "minItems": 1, "uniqueItems": true }, + "sponsored_placement_types": { "type": "array", "items": { "$ref": "/schemas/enums/sponsored-placement-type.json" }, "minItems": 1, "uniqueItems": true }, + "social_placement_surfaces": { "type": "array", "items": { "$ref": "/schemas/enums/social-placement-surface.json" }, "minItems": 1, "uniqueItems": true }, + "format_options": { "type": "array", "items": { "$ref": "/schemas/core/canonical-format-option.json" }, "minItems": 1 }, + "placements": { "type": "array", "items": { "$ref": "/schemas/core/canonical-placement.json" } }, + "delivery_type": { "$ref": "/schemas/enums/delivery-type.json" }, + "exclusivity": { "$ref": "/schemas/enums/exclusivity.json" }, + "pricing_options": { "type": "array", "items": { "$ref": "/schemas/core/canonical-pricing-option.json" }, "minItems": 1 }, + "forecast": { "$ref": "/schemas/core/canonical-delivery-forecast.json" }, + "reporting_capabilities": { "$ref": "/schemas/core/canonical-reporting-capabilities.json" }, + "measurement_terms": { + "$ref": "/schemas/core/canonical-measurement-terms.json", + "description": "Default billing measurement and makegood terms inherited by a direct purchase unless a negotiated proposal replaces them." + }, + "performance_standards": { + "type": "array", + "items": { "$ref": "/schemas/core/canonical-performance-standard.json" }, + "minItems": 1, + "description": "Default performance thresholds and measurement vendors inherited by a direct purchase." + }, + "catalog_types": { "type": "array", "items": { "$ref": "/schemas/enums/catalog-type.json" }, "minItems": 1, "uniqueItems": true }, + "signal_targeting_allowed": { "type": "boolean" }, + "signal_targeting_rules": { "$ref": "/schemas/core/signal-targeting-rules.json" }, + "demographic_targeting": { "$ref": "/schemas/core/demographic-targeting-capability.json" }, + "audience_evidence": { + "type": "array", + "items": { "$ref": "/schemas/core/canonical-audience-evidence.json" }, + "minItems": 1 + }, + "audience_evidence_selections": { + "type": "array", + "items": { "$ref": "/schemas/core/canonical-audience-evidence-selection.json" }, + "minItems": 1, + "description": "Exact evidence snapshots that affected eligibility or ranking. Returned whenever evidence requirements affected the result, even if not requested explicitly." + }, + "max_optimization_goals": { "type": "integer", "minimum": 0 }, + "catalog_match": { "type": "object", "properties": { - "format_options": { - "type": "array", - "items": { - "not": { "required": ["v1_format_ref"] } - } - }, - "placements": { - "type": "array", - "items": { - "type": "object", - "properties": { - "format_options": { - "type": "array", - "items": { - "not": { "required": ["v1_format_ref"] } - } - } - }, - "not": { "required": ["format_ids"] } - } - } + "matched_gtins": { "type": "array", "items": { "type": "string", "pattern": "^[0-9]{8,14}$" } }, + "matched_ids": { "type": "array", "items": { "type": "string" } }, + "matched_count": { "type": "integer", "minimum": 0 }, + "submitted_count": { "type": "integer", "minimum": 0 } }, - "required": ["format_options"], - "not": { "required": ["format_ids"] } - } - ] + "required": ["submitted_count"], + "additionalProperties": false + }, + "brief_relevance": { "type": "string" }, + "expires_at": { "type": "string", "format": "date-time" }, + "allowed_actions": { + "type": "array", + "items": { "$ref": "/schemas/core/canonical-product-action.json" }, + "uniqueItems": true + }, + "ext": { "$ref": "/schemas/core/ext.json" } + }, + "required": ["product_id", "name"], + "additionalProperties": false } diff --git a/static/schemas/source/core/canonical-proposal.json b/static/schemas/source/core/canonical-proposal.json new file mode 100644 index 0000000000..e606c120d2 --- /dev/null +++ b/static/schemas/source/core/canonical-proposal.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-proposal.json", + "title": "Canonical Proposal", + "description": "Compact immutable proposal for the AdCP 3.2 lifecycle. commercial_terms is the sole authoritative commercial envelope; narrative fields do not duplicate legacy allocation or creative graphs.", + "type": "object", + "properties": { + "proposal_id": { "type": "string", "minLength": 1, "maxLength": 255, "x-entity": "proposal" }, + "proposal_kind": { "type": "string", "enum": ["new_media_buy", "media_buy_update", "media_buy_cancellation"] }, + "parent_proposal_id": { "type": "string", "minLength": 1, "maxLength": 255, "x-entity": "proposal" }, + "media_buy_id": { "type": "string", "minLength": 1, "x-entity": "media_buy" }, + "opportunity_id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$", + "x-entity": "opportunity", + "description": "Buyer planning cycle associated with this proposal. Revisions inherit it; it does not participate in proposal identity." + }, + "base_media_buy_revision": { "type": "integer", "minimum": 1 }, + "proposal_status": { "$ref": "/schemas/enums/proposal-status.json" }, + "accepted_at": { "type": "string", "format": "date-time" }, + "expires_at": { "type": "string", "format": "date-time" }, + "name": { "type": "string", "minLength": 1, "maxLength": 500 }, + "description": { "type": "string", "maxLength": 2000 }, + "brief_alignment": { "type": "string", "maxLength": 2000 }, + "commercial_terms": { "$ref": "/schemas/media-buy/commercial-terms.json" }, + "terms_digest": { + "type": "string", + "pattern": "^sha256:[A-Za-z0-9_-]{43}$", + "description": "Base64url SHA-256 digest of the RFC 8785 JCS serialization of commercial_terms, prefixed with sha256:." + }, + "insertion_order": { "$ref": "/schemas/core/insertion-order.json" } + }, + "required": ["proposal_id", "proposal_kind", "proposal_status", "name", "commercial_terms", "terms_digest"], + "allOf": [ + { + "if": { "properties": { "proposal_kind": { "enum": ["media_buy_update", "media_buy_cancellation"] } }, "required": ["proposal_kind"] }, + "then": { "required": ["parent_proposal_id", "media_buy_id", "base_media_buy_revision"] } + }, + { + "if": { "properties": { "proposal_status": { "const": "accepted" } }, "required": ["proposal_status"] }, + "then": { "required": ["media_buy_id", "accepted_at"] } + }, + { + "if": { "properties": { "proposal_status": { "const": "committed" } }, "required": ["proposal_status"] }, + "then": { "required": ["expires_at"] } + }, + { + "if": { "properties": { "proposal_kind": { "const": "media_buy_cancellation" } }, "required": ["proposal_kind"] }, + "then": { "properties": { "commercial_terms": { "required": ["cancellation_terms"] } } } + } + ], + "additionalProperties": false +} diff --git a/static/schemas/source/core/canonical-reporting-capabilities.json b/static/schemas/source/core/canonical-reporting-capabilities.json new file mode 100644 index 0000000000..67ab5b3b3a --- /dev/null +++ b/static/schemas/source/core/canonical-reporting-capabilities.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-reporting-capabilities.json", + "title": "Canonical Reporting Capabilities", + "description": "Compact product reporting contract. Vendor metrics use identity-only BrandKey references and do not inline brand or creative assets.", + "type": "object", + "properties": { + "available_reporting_frequencies": { "type": "array", "items": { "$ref": "/schemas/enums/reporting-frequency.json" }, "minItems": 1, "uniqueItems": true }, + "expected_delay_minutes": { "type": "integer", "minimum": 0 }, + "timezone": { "type": "string" }, + "supports_webhooks": { "type": "boolean" }, + "available_metrics": { "type": "array", "items": { "$ref": "/schemas/enums/available-metric.json" }, "uniqueItems": true }, + "vendor_metrics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "vendor": { "$ref": "/schemas/core/brand-key.json" }, + "metric_id": { "$ref": "/schemas/core/vendor-metric-id.json" } + }, + "required": ["vendor", "metric_id"], + "additionalProperties": false + } + }, + "supports_creative_breakdown": { "type": "boolean" }, + "supports_keyword_breakdown": { "type": "boolean" }, + "supports_geo_breakdown": { "$ref": "/schemas/core/geo-breakdown-support.json" }, + "supports_device_type_breakdown": { "type": "boolean" }, + "supports_device_platform_breakdown": { "type": "boolean" }, + "supports_audience_breakdown": { "type": "boolean" }, + "supports_placement_breakdown": { "type": "boolean" }, + "date_range_support": { "type": "string", "enum": ["date_range", "lifetime_only"] }, + "windowed_pull_granularities": { "type": "array", "items": { "$ref": "/schemas/enums/reporting-frequency.json" }, "uniqueItems": true }, + "measurement_windows": { "type": "array", "items": { "$ref": "/schemas/core/measurement-window.json" }, "minItems": 1, "uniqueItems": true } + }, + "required": ["available_reporting_frequencies", "expected_delay_minutes", "timezone", "supports_webhooks", "available_metrics", "date_range_support"], + "additionalProperties": false +} diff --git a/static/schemas/source/core/canonical-reporting-commitment.json b/static/schemas/source/core/canonical-reporting-commitment.json new file mode 100644 index 0000000000..fefa4b5676 --- /dev/null +++ b/static/schemas/source/core/canonical-reporting-commitment.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-reporting-commitment.json", + "title": "Canonical Reporting Commitment", + "description": "One metric the seller commits to report for a commercial product term. Vendor identity uses BrandKey so the compact lifecycle does not inline brand or provenance documents.", + "type": "object", + "discriminator": { + "propertyName": "scope" + }, + "oneOf": [ + { + "properties": { + "scope": { "type": "string", "const": "standard" }, + "metric_id": { "$ref": "/schemas/enums/available-metric.json" }, + "qualifier": { "$ref": "/schemas/core/canonical-metric-qualifier.json" }, + "effective_at": { "type": "string", "format": "date-time" } + }, + "required": ["scope", "metric_id"], + "additionalProperties": false + }, + { + "properties": { + "scope": { "type": "string", "const": "vendor" }, + "vendor": { "$ref": "/schemas/core/brand-key.json" }, + "metric_id": { "$ref": "/schemas/core/vendor-metric-id.json" }, + "qualifier": { "$ref": "/schemas/core/canonical-metric-qualifier.json" }, + "effective_at": { "type": "string", "format": "date-time" } + }, + "required": ["scope", "vendor", "metric_id"], + "additionalProperties": false + } + ] +} diff --git a/static/schemas/source/core/compact-task-input-required.json b/static/schemas/source/core/compact-task-input-required.json new file mode 100644 index 0000000000..2ca6eaa94f --- /dev/null +++ b/static/schemas/source/core/compact-task-input-required.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/compact-task-input-required.json", + "title": "Compact Task Input Required", + "description": "Bounded payload for a compact lifecycle task paused for buyer input or approval.", + "type": "object", + "properties": { + "reason": { "type": "string", "minLength": 1, "maxLength": 200 }, + "message": { "type": "string", "maxLength": 2000 }, + "errors": { + "type": "array", + "items": { "$ref": "/schemas/core/error.json" } + }, + "context": { "$ref": "/schemas/core/context.json" }, + "ext": { "$ref": "/schemas/core/ext.json" } + }, + "additionalProperties": true +} diff --git a/static/schemas/source/core/compact-task-submitted.json b/static/schemas/source/core/compact-task-submitted.json new file mode 100644 index 0000000000..d0d3e52c34 --- /dev/null +++ b/static/schemas/source/core/compact-task-submitted.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/compact-task-submitted.json", + "title": "Compact Task Submitted", + "description": "Bounded submitted envelope shared by the compact lifecycle tools.", + "type": "object", + "properties": { + "status": { "type": "string", "const": "submitted" }, + "task_id": { "type": "string", "minLength": 1, "x-entity": "task" }, + "message": { "type": "string", "maxLength": 2000 }, + "errors": { + "type": "array", + "items": { "$ref": "/schemas/core/error.json" } + }, + "context": { "$ref": "/schemas/core/context.json" }, + "ext": { "$ref": "/schemas/core/ext.json" } + }, + "required": ["status", "task_id"], + "additionalProperties": true +} diff --git a/static/schemas/source/core/compact-task-working.json b/static/schemas/source/core/compact-task-working.json new file mode 100644 index 0000000000..515b1c68d5 --- /dev/null +++ b/static/schemas/source/core/compact-task-working.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/compact-task-working.json", + "title": "Compact Task Working", + "description": "Bounded progress payload shared by the compact lifecycle tools.", + "type": "object", + "properties": { + "percentage": { "type": "number", "minimum": 0, "maximum": 100 }, + "current_step": { "type": "string", "maxLength": 500 }, + "total_steps": { "type": "integer", "minimum": 1 }, + "step_number": { "type": "integer", "minimum": 1 }, + "context": { "$ref": "/schemas/core/context.json" }, + "ext": { "$ref": "/schemas/core/ext.json" } + }, + "additionalProperties": true +} diff --git a/static/schemas/source/core/keyword-target.json b/static/schemas/source/core/keyword-target.json new file mode 100644 index 0000000000..9b4c2b1006 --- /dev/null +++ b/static/schemas/source/core/keyword-target.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/keyword-target.json", + "title": "Keyword Target", + "description": "A keyword and match-mode identity used by canonical package targeting controls.", + "type": "object", + "properties": { + "keyword": { "type": "string", "minLength": 1 }, + "match_type": { "$ref": "/schemas/enums/match-type.json" }, + "bid_price": { "type": "number", "minimum": 0 } + }, + "required": ["keyword", "match_type"], + "additionalProperties": false +} diff --git a/static/schemas/source/core/media-buy-available-action.json b/static/schemas/source/core/media-buy-available-action.json index 9fcb4f0e47..8093cac6fa 100644 --- a/static/schemas/source/core/media-buy-available-action.json +++ b/static/schemas/source/core/media-buy-available-action.json @@ -13,6 +13,11 @@ "$ref": "/schemas/enums/media-buy-action-mode.json", "description": "The single mode that applies right now on this buy for this action. Singular because the buy has a concrete state, exactly one mode applies. Buyer SDKs branch on this to decide whether to expect a synchronous response, conditional handling, or an asynchronous approval callback." }, + "task": { + "type": "string", + "enum": ["control_media_buy", "refine_proposals", "sync_creatives"], + "description": "Compact-lifecycle task for this resolved action: operational control, commercial refinement, or creative lifecycle mutation." + }, "sla": { "$ref": "/schemas/core/sla-window.json", "description": "Optional SLA commitment for this action on this buy. Absence means no commitment, not zero commitment." diff --git a/static/schemas/source/core/media-buy-features.json b/static/schemas/source/core/media-buy-features.json index d799951c5c..4a3ad9fc7d 100644 --- a/static/schemas/source/core/media-buy-features.json +++ b/static/schemas/source/core/media-buy-features.json @@ -7,7 +7,8 @@ "properties": { "inline_creative_management": { "type": "boolean", - "description": "Supports creatives provided inline in create_media_buy and update_media_buy package payloads. This flag does not imply a creative library: an inline-only seller can accept packages[].creatives without advertising sync_creatives, list_creatives, or reusable creative IDs." + "deprecated": true, + "description": "Deprecated 3.x compatibility capability for creatives provided inline in create_media_buy and update_media_buy package payloads. buy_products, accept_proposal, and control_media_buy never accept inline creatives. New integrations use the dedicated creative lifecycle. Removed in 4.0." }, "property_list_filtering": { "type": "boolean", diff --git a/static/schemas/source/core/media-buy.json b/static/schemas/source/core/media-buy.json index 284077dfbc..e336e003bb 100644 --- a/static/schemas/source/core/media-buy.json +++ b/static/schemas/source/core/media-buy.json @@ -10,6 +10,18 @@ "description": "Seller's unique identifier for the media buy", "x-entity": "media_buy" }, + "accepted_proposal_id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "x-entity": "proposal", + "description": "Current accepted commercial snapshot. Compact-lifecycle buyers pass this ID to refine_proposals after restart or handoff. Updated atomically when an amendment or negotiated cancellation is accepted." + }, + "accepted_proposal_terms_digest": { + "type": "string", + "pattern": "^sha256:[A-Za-z0-9_-]{43}$", + "description": "Digest of the current accepted proposal commercial_terms, allowing buyers and governance agents to verify the recovered snapshot." + }, "account": { "$ref": "/schemas/core/account.json", "description": "Account billed for this media buy" @@ -50,7 +62,7 @@ }, "canceled_by": { "$ref": "/schemas/enums/canceled-by.json", - "description": "Which party initiated the cancellation. 'buyer' when canceled via update_media_buy; 'seller' when the seller cancels (e.g., policy violation, inventory withdrawal)." + "description": "Which party initiated the cancellation. 'buyer' when canceled through control_media_buy or the 3.x update_media_buy facade; 'seller' when the seller cancels (e.g., policy violation, inventory withdrawal)." }, "reason": { "type": "string", @@ -127,6 +139,10 @@ "$ref": "/schemas/core/ext.json" } }, + "dependencies": { + "accepted_proposal_id": ["accepted_proposal_terms_digest"], + "accepted_proposal_terms_digest": ["accepted_proposal_id"] + }, "allOf": [ { "if": { diff --git a/static/schemas/source/core/notification-config.json b/static/schemas/source/core/notification-config.json index 7335c1ed5c..f8f4e83b2e 100644 --- a/static/schemas/source/core/notification-config.json +++ b/static/schemas/source/core/notification-config.json @@ -40,6 +40,12 @@ "minItems": 1, "uniqueItems": true }, + "product_payload_view": { + "type": "string", + "enum": ["canonical", "legacy"], + "default": "legacy", + "description": "Product webhook representation selected by this subscriber. Use canonical with lifecycle_tools.list_products; legacy is the default for 3.x get_products consumers. Sellers emit exactly canonical_product/canonical_pricing_options or product/pricing_options accordingly. Valid only when event_types includes a product.* event." + }, "authentication": { "type": "object", "deprecated": true, @@ -78,6 +84,18 @@ "url", "event_types" ], + "allOf": [ + { + "if": { "required": ["product_payload_view"] }, + "then": { + "properties": { + "event_types": { + "contains": { "enum": ["product.created", "product.updated", "product.priced", "product.removed"] } + } + } + } + } + ], "additionalProperties": false, "examples": [ { @@ -131,6 +149,7 @@ "signal.removed", "wholesale_feed.bulk_change" ], + "product_payload_view": "canonical", "active": true } }, diff --git a/static/schemas/source/core/planned-delivery.json b/static/schemas/source/core/planned-delivery.json index c61c198d34..4748a6b610 100644 --- a/static/schemas/source/core/planned-delivery.json +++ b/static/schemas/source/core/planned-delivery.json @@ -11,6 +11,18 @@ "description": "Seller-assigned media buy identifier. Optional on a purchase-phase prepare/check because the service may not assign the identifier until commit; required on modification and delivery lifecycle checks.", "x-entity": "media_buy" }, + "proposal_id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "x-entity": "proposal", + "description": "Proposal snapshot being executed or currently governing the MediaBuy." + }, + "proposal_terms_digest": { + "type": "string", + "pattern": "^sha256:[A-Za-z0-9_-]{43}$", + "description": "Digest of the proposal commercial_terms. The governance agent compares it to the digest bound during the intent check." + }, "geo": { "type": "object", "description": "Geographic targeting the seller will apply.", @@ -96,5 +108,15 @@ "$ref": "/schemas/core/ext.json" } }, + "allOf": [ + { + "if": { "required": ["proposal_id"] }, + "then": { "required": ["proposal_terms_digest"] } + }, + { + "if": { "required": ["proposal_terms_digest"] }, + "then": { "required": ["proposal_id"] } + } + ], "additionalProperties": true } diff --git a/static/schemas/source/core/product-offer-filters.json b/static/schemas/source/core/product-offer-filters.json index 8382ba6456..bac2bf3c60 100644 --- a/static/schemas/source/core/product-offer-filters.json +++ b/static/schemas/source/core/product-offer-filters.json @@ -113,9 +113,9 @@ "additionalProperties": false }, "required_features": { - "allOf": [{ "$ref": "/schemas/core/media-buy-features.json" }], - "description": "Protocol features the seller must support.", - "x-adcp-schema-uri": "/schemas/core/media-buy-features.json" + "allOf": [{ "$ref": "/schemas/core/canonical-media-buy-features.json" }], + "description": "Canonical protocol features the seller must support.", + "x-adcp-schema-uri": "/schemas/core/canonical-media-buy-features.json" }, "required_performance_standards": { "type": "array", diff --git a/static/schemas/source/core/proposal.json b/static/schemas/source/core/proposal.json index 490b3971ba..f549a44164 100644 --- a/static/schemas/source/core/proposal.json +++ b/static/schemas/source/core/proposal.json @@ -2,13 +2,12 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/proposal.json", "title": "Proposal", - "description": "A proposed media-plan snapshot with fixed or seller-optimized budget allocation across products. In the AdCP 3.2 split proposal lifecycle, request_proposals and refine_proposals return executable committed snapshots; changed commercial terms produce a new proposal_id while buyer-disposition state may progress on the same snapshot. Legacy get_products draft/finalize behavior remains compatible throughout 3.x. Committed proposals execute through create_media_buy(proposal_id), and proposals declined through the split lifecycle cannot execute.", + "description": "A proposed media plan with fixed or seller-optimized budget allocation across products. Represents the publisher's strategic recommendation for how to structure a campaign based on the brief. Proposals are actionable: committed proposals can be executed directly via create_media_buy by providing the proposal_id; draft proposals must first be finalized via get_products refine action 'finalize'.", "type": "object", "properties": { "proposal_id": { "type": "string", - "description": "Unique identifier for this commercial snapshot. Used to refine or decline a proposal and to execute it via create_media_buy. For proposals issued by the AdCP 3.2 split tasks, a seller MUST issue a new ID whenever refinement changes terms.", - "x-entity": "proposal", + "description": "Unique identifier for this proposal. Used to finalize a draft proposal and to execute a committed proposal via create_media_buy.", "maxLength": 255 }, "name": { @@ -43,12 +42,12 @@ }, "proposal_status": { "$ref": "/schemas/enums/proposal-status.json", - "description": "Lifecycle status of this proposal. AdCP 3.2 split proposal tasks return 'committed' snapshots that are immediately executable via create_media_buy. Legacy get_products may still return 'draft' proposals requiring its 3.x finalize refinement. When absent, the proposal is ready to buy for backward compatibility." + "description": "Lifecycle status of this proposal and the per-proposal source of truth for whether finalization is required before create_media_buy. When absent, the proposal is ready to buy (backward compatible). 'draft' means indicative pricing — finalize via refine before purchasing. 'committed' means firm pricing with inventory reserved until expires_at and executable via create_media_buy." }, "expires_at": { "type": "string", "format": "date-time", - "description": "When this proposal expires and can no longer be executed. For draft proposals, indicates when indicative pricing becomes stale. For committed proposals, this is the deadline for create_media_buy. Commitment makes the terms executable but does not by itself assert an inventory hold; when the proposal terms include a hold, it lasts no later than this timestamp." + "description": "When this proposal expires and can no longer be executed. For draft proposals, indicates when indicative pricing becomes stale. For committed proposals, indicates when the inventory hold lapses — the buyer must call create_media_buy before this time." }, "insertion_order": { "$ref": "/schemas/core/insertion-order.json", diff --git a/static/schemas/source/core/targeting.json b/static/schemas/source/core/targeting.json index 2d1bb5edc0..65e5c8b2a6 100644 --- a/static/schemas/source/core/targeting.json +++ b/static/schemas/source/core/targeting.json @@ -141,7 +141,7 @@ }, "signal_targeting_groups": { "$ref": "/schemas/core/package-signal-targeting-groups.json", - "description": "Basic Boolean grouping for seller-offered signals. v1 supports a required top-level operator 'all' and child groups with operator 'any' for include groups or 'none' for exclusion groups. Example semantics: group 1 any(A, B) plus group 2 none(C, D) means (A OR B) AND NOT (C OR D). Signal entries reference named signal definitions with signal_ref scope 'product' for product-local signal options or scope 'data_provider' for external signals published in adagents.json signals[]. For simple include-only targeting, send one child group with operator 'any'. Sellers SHOULD reject entries that are not available for the product through inline signal_targeting_options or get_signals, are not active for the account, or exceed the product's signal_targeting_allowed/signal_targeting_rules/product terms. Signal targeting limits are product-scoped, not declared in get_adcp_capabilities, because products may be backed by different ad servers. Sellers MUST echo applied signal_targeting_groups on the resulting package state, including fixed/default selections. On update_media_buy, sellers MAY reject changes that require repricing with REQUOTE_REQUIRED." + "description": "Basic Boolean grouping for seller-offered signals. v1 supports a required top-level operator 'all' and child groups with operator 'any' for include groups or 'none' for exclusion groups. Example semantics: group 1 any(A, B) plus group 2 none(C, D) means (A OR B) AND NOT (C OR D). Signal entries reference named signal definitions with signal_ref scope 'product' for product-local signal options or scope 'data_provider' for external signals published in adagents.json signals[]. For simple include-only targeting, send one child group with operator 'any'. Sellers SHOULD reject entries that are not available for the product through inline signal_targeting_options or get_signals, are not active for the account, or exceed the product's signal_targeting_allowed/signal_targeting_rules/product terms. Signal targeting limits are product-scoped, not declared in get_adcp_capabilities, because products may be backed by different ad servers. Sellers MUST echo applied signal_targeting_groups on the resulting package state, including fixed/default selections. Sellers MAY return REQUOTE_REQUIRED when a targeting mutation changes commercial terms." }, "signal_targeting": { "type": "array", diff --git a/static/schemas/source/core/wholesale-feed-event.json b/static/schemas/source/core/wholesale-feed-event.json index 030866454c..b92b969bdb 100644 --- a/static/schemas/source/core/wholesale-feed-event.json +++ b/static/schemas/source/core/wholesale-feed-event.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/wholesale-feed-event.json", "title": "Wholesale Feed Event", - "description": "A single change event emitted by an AdCP agent's wholesale product feed or wholesale signals feed and delivered inside wholesale-feed-webhook payloads. Events are denormalized — the payload carries the post-change state of a buyable product or signal so consumers can update local state without a follow-up get_products / get_signals call. This is distinct from buyer-provided feeds managed by sync_catalogs. The discriminator is `event_type`; each branch defines the payload shape. See specs/wholesale-feed-webhooks.md for webhook delivery and reconciliation semantics.", + "description": "A single change event emitted by an AdCP agent's wholesale product feed or wholesale signals feed and delivered inside wholesale-feed-webhook payloads. Events are denormalized so consumers can update local state without polling list_products / get_signals. This is distinct from buyer-provided feeds managed by sync_catalogs. The discriminator is event_type; each branch defines the payload shape.", "type": "object", "discriminator": { "propertyName": "event_type" @@ -19,7 +19,7 @@ "event_id": { "type": "string", "format": "uuid", - "description": "Stable logical event identifier for this wholesale feed change. MUST equal the enclosing webhook `notification_id`. UUID v7 is RECOMMENDED so receivers can detect obvious out-of-order delivery, but there is no durable cursor or event-polling API; missed or distrusted pushes are repaired through get_products / get_signals." + "description": "Stable logical event identifier for this wholesale feed change. MUST equal the enclosing webhook notification_id. UUID v7 is RECOMMENDED so receivers can detect obvious out-of-order delivery; missed or distrusted pushes are repaired through list_products / get_signals." }, "event_type": { "type": "string", @@ -71,24 +71,29 @@ }, "payload": { "type": "object", - "required": [ - "product_id", - "product", - "applies_to" - ], + "required": ["product_id", "applies_to"], "properties": { "product_id": { "type": "string" }, "product": { "$ref": "/schemas/core/product.json", - "description": "Full post-change Product object." + "description": "Legacy 3.x post-change Product. Subscribers using get_products consume this form." + }, + "canonical_product": { + "$ref": "/schemas/core/canonical-product.json", + "description": "Canonical post-change Product for subscribers that negotiated lifecycle_tools.list_products. Consumers replace the matching canonical mirror entry directly." }, "applies_to": { "$ref": "#/$defs/appliesTo", "description": "REQUIRED. Sellers MUST declare the cache layer explicitly on every *.created event. When introducing an entity that exists only in an account overlay (e.g., a custom product for a single account), the seller MUST emit { scope: 'account', account_ids: [...] } to prevent the entity from leaking into every consumer's public-layer cache. For public-layer additions, declare { scope: 'public' } explicitly rather than relying on a default — schema-required declaration prevents the quiet-failure path where a forgotten applies_to leaks an account-only entity to all consumers." } }, + "anyOf": [ + { "required": ["product"] }, + { "required": ["canonical_product"] } + ], + "not": { "required": ["product", "canonical_product"] }, "additionalProperties": true } }, @@ -107,18 +112,18 @@ }, "payload": { "type": "object", - "required": [ - "product_id", - "product", - "applies_to" - ], + "required": ["product_id", "applies_to"], "properties": { "product_id": { "type": "string" }, "product": { "$ref": "/schemas/core/product.json", - "description": "Full post-change Product object. Consumers replace the prior product mirror entry with this object." + "description": "Legacy 3.x post-change Product for get_products mirrors." + }, + "canonical_product": { + "$ref": "/schemas/core/canonical-product.json", + "description": "Canonical post-change Product for list_products mirrors." }, "changed_fields": { "type": "array", @@ -131,6 +136,11 @@ "$ref": "#/$defs/appliesTo" } }, + "anyOf": [ + { "required": ["product"] }, + { "required": ["canonical_product"] } + ], + "not": { "required": ["product", "canonical_product"] }, "additionalProperties": true } }, @@ -149,11 +159,7 @@ }, "payload": { "type": "object", - "required": [ - "product_id", - "pricing_options", - "applies_to" - ], + "required": ["product_id", "applies_to"], "properties": { "product_id": { "type": "string" @@ -166,6 +172,12 @@ }, "description": "Full post-change pricing_options array. NOT a delta — consumers replace the prior options with this list." }, + "canonical_pricing_options": { + "type": "array", + "minItems": 1, + "items": { "$ref": "/schemas/core/canonical-pricing-option.json" }, + "description": "Full post-change canonical pricing array for list_products mirrors." + }, "previous_pricing_option_ids": { "type": "array", "items": { @@ -187,6 +199,11 @@ "$ref": "#/$defs/appliesTo" } }, + "anyOf": [ + { "required": ["pricing_options"] }, + { "required": ["canonical_pricing_options"] } + ], + "not": { "required": ["pricing_options", "canonical_pricing_options"] }, "additionalProperties": true } }, @@ -406,7 +423,7 @@ ] }, { - "description": "wholesale_feed.bulk_change — fast-forward event emitted when a single operation affects many entities in one feed. Consumers SHOULD repair by re-reading the affected wholesale feed named by affected_entity_type via get_products or get_signals.", + "description": "wholesale_feed.bulk_change — fast-forward event emitted when a single operation affects many entities in one feed. Consumers SHOULD repair products through list_products and signals through get_signals.", "properties": { "event_type": { "const": "wholesale_feed.bulk_change" diff --git a/static/schemas/source/core/wholesale-feed-webhook.json b/static/schemas/source/core/wholesale-feed-webhook.json index b2c5ebb3ae..d47f465102 100644 --- a/static/schemas/source/core/wholesale-feed-webhook.json +++ b/static/schemas/source/core/wholesale-feed-webhook.json @@ -2,8 +2,18 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/wholesale-feed-webhook.json", "title": "Wholesale Feed Webhook", - "description": "Account-level webhook payload fired when a seller's wholesale product feed or wholesale signals feed changes. Registered through sync_accounts.accounts[].notification_configs[] using product.* / signal.* / wholesale_feed.bulk_change event_types. The payload carries the actual change event so receivers can update a local mirror without immediately re-reading the full feed. get_products / get_signals with if_wholesale_feed_version remain the repair and reconciliation path after missed, stale, or distrusted pushes. Capability consistency: product.* webhooks require wholesale get_products support; signal.* webhooks require wholesale get_signals support; wholesale_feed.bulk_change must name only a feed family backed by a declared wholesale repair path.", + "description": "Account-level webhook payload fired when a seller's wholesale product feed or wholesale signals feed changes. Registered through sync_accounts.accounts[].notification_configs[] using product.* / signal.* / wholesale_feed.bulk_change event_types. The payload carries the actual change event so receivers can update a local mirror without polling. list_products(if_feed_version) and get_signals(if_wholesale_feed_version) are the canonical repair paths after missed, stale, or distrusted pushes; wholesale get_products remains the 3.x product compatibility path.", "type": "object", + "x-adcp-validation": { + "verifier_constraints": { + "product_payload_negotiation": { + "canonical": "product_payload_view_canonical_requires_canonical_product_or_canonical_pricing_options", + "legacy_or_omitted": "requires_product_or_pricing_options", + "selected_view": "equals_registered_notification_config.product_payload_view", + "on_violation": "reject_webhook" + } + } + }, "properties": { "idempotency_key": { "type": "string", @@ -51,7 +61,12 @@ }, "wholesale_feed_version": { "type": "string", - "description": "Opaque version token for the affected wholesale feed after this change. Receivers store it with their mirror and can pass it to get_products / get_signals as if_wholesale_feed_version to verify whether their local state is current." + "description": "Opaque post-change version token for the affected wholesale feed. Store it only after applying the event. A stale mirror repairs with its last applied version; uncertain or bulk repair omits the conditional token." + }, + "product_payload_view": { + "type": "string", + "enum": ["canonical", "legacy"], + "description": "Product representation selected by the receiving notification config. Present on product.* fires; canonical uses canonical_product/canonical_pricing_options and legacy uses product/pricing_options." }, "previous_wholesale_feed_version": { "type": "string", @@ -63,11 +78,11 @@ "public", "account" ], - "description": "Cache layer affected by this change. MUST equal event.payload.applies_to.scope. Mirrors the cache_scope returned by get_products / get_signals for the affected wholesale feed." + "description": "Cache layer affected by this change. MUST equal event.payload.applies_to.scope. Mirrors the cache_scope returned by list_products / get_signals for the affected wholesale feed." }, "event": { "$ref": "/schemas/core/wholesale-feed-event.json", - "description": "The actual product, signal, or bulk-change event. Consumers MAY apply this payload to their local mirror. Before any binding action, or when ordering/gap checks fail, consumers MUST reconcile through get_products / get_signals." + "description": "The actual product, signal, or bulk-change event. Consumers MAY apply this payload to their local mirror. Before any binding action, or when ordering/gap checks fail, consumers MUST reconcile through list_products / get_signals." }, "ext": { "$ref": "/schemas/core/ext.json" @@ -96,6 +111,7 @@ "subscriber_id": "wholesale-feed-sync", "account_id": "acc_acme_pinnacle", "wholesale_feed_version": "v2026-05-18T10:00:00Z-acme-rev413", + "product_payload_view": "legacy", "previous_wholesale_feed_version": "v2026-05-18T08:00:00Z-acme-rev412", "cache_scope": "public", "event": { @@ -126,6 +142,23 @@ } ], "allOf": [ + { + "description": "Product events echo the representation negotiated by the subscriber; non-product events do not carry a product view.", + "if": { + "properties": { + "notification_type": { + "enum": ["product.created", "product.updated", "product.priced", "product.removed"] + } + }, + "required": ["notification_type"] + }, + "then": { + "required": ["product_payload_view"] + }, + "else": { + "not": { "required": ["product_payload_view"] } + } + }, { "if": { "properties": { diff --git a/static/schemas/source/creative/sync-creatives-request.json b/static/schemas/source/creative/sync-creatives-request.json index 82dfddfdaf..586a8e9767 100644 --- a/static/schemas/source/creative/sync-creatives-request.json +++ b/static/schemas/source/creative/sync-creatives-request.json @@ -7,6 +7,30 @@ "allOf": [ { "$ref": "/schemas/core/version-envelope.json" + }, + { + "if": { + "properties": { + "delete_missing": { "const": true } + }, + "required": ["delete_missing"] + }, + "then": { + "required": ["creatives"] + } + }, + { + "not": { + "required": ["assignments", "assignment_operations"] + } + }, + { + "if": { "required": ["assignment_operations"] }, + "then": { + "properties": { + "validation_mode": { "type": "string", "const": "strict" } + } + } } ], "x-mutates-state": true, @@ -74,7 +98,8 @@ }, "assignments": { "type": "array", - "description": "Optional bulk assignment of creatives to packages. Each entry maps one creative to one package with optional weight and placement targeting. Standalone creative agents that do not manage media buys ignore this field.", + "deprecated": true, + "description": "Deprecated additive assignment shorthand. Each entry upserts one creative-to-package assignment. Use assignment_operations for explicit assign, unassign, and replace semantics. Standalone creative agents that do not manage media buys ignore this field.", "items": { "type": "object", "properties": { @@ -125,6 +150,93 @@ }, "minItems": 1 }, + "assignment_operations": { + "type": "array", + "description": "Explicit, ordered assignment mutations. These operations may be sent without creatives to traffic existing creative IDs independently from MediaBuy commercial control. The entire request is atomic under idempotency_key and therefore requires strict validation; lenient partial processing is not permitted.", + "minItems": 1, + "maxItems": 500, + "items": { + "type": "object", + "discriminator": { + "propertyName": "operation" + }, + "oneOf": [ + { + "title": "Assign or update", + "properties": { + "operation": { "const": "assign" }, + "creative_id": { "type": "string", "minLength": 1, "x-entity": "creative" }, + "package_id": { "type": "string", "minLength": 1, "x-entity": "package" }, + "weight": { "type": "number", "minimum": 0, "maximum": 100 }, + "placement_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": ["operation", "creative_id", "package_id"], + "additionalProperties": false, + "x-adcp-validation": { + "verifier_constraints": { + "product_format_locale_policy": { + "scope": "every_effective_product_and_placement_format_option_where_assignment_may_serve", + "range_matching": "rfc4647_basic_filtering", + "eligible_variant_set": "filter_before_buyer_lookup_fallback_or_default", + "minimum_eligible_variants_per_scope": 1, + "serve_default": "default_locale_variant_id_must_be_eligible", + "policy_lifecycle": "snapshot_effective_policy_at_assignment_acceptance", + "on_violation": "CREATIVE_LOCALE_NOT_ACCEPTED" + } + }, + "spec": "docs/creative/task-reference/sync_creatives.mdx#native-localization" + } + }, + { + "title": "Unassign", + "properties": { + "operation": { "const": "unassign" }, + "creative_id": { "type": "string", "minLength": 1, "x-entity": "creative" }, + "package_id": { "type": "string", "minLength": 1, "x-entity": "package" } + }, + "required": ["operation", "creative_id", "package_id"], + "additionalProperties": false + }, + { + "title": "Replace assignment", + "properties": { + "operation": { "const": "replace" }, + "creative_id": { "type": "string", "minLength": 1, "description": "Replacement creative ID.", "x-entity": "creative" }, + "replaces_creative_id": { "type": "string", "minLength": 1, "description": "Currently assigned creative ID to remove atomically.", "x-entity": "creative" }, + "package_id": { "type": "string", "minLength": 1, "x-entity": "package" }, + "weight": { "type": "number", "minimum": 0, "maximum": 100 }, + "placement_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": ["operation", "creative_id", "replaces_creative_id", "package_id"], + "additionalProperties": false, + "x-adcp-validation": { + "verifier_constraints": { + "product_format_locale_policy": { + "scope": "every_effective_product_and_placement_format_option_where_assignment_may_serve", + "range_matching": "rfc4647_basic_filtering", + "eligible_variant_set": "filter_before_buyer_lookup_fallback_or_default", + "minimum_eligible_variants_per_scope": 1, + "serve_default": "default_locale_variant_id_must_be_eligible", + "policy_lifecycle": "snapshot_effective_policy_at_assignment_acceptance", + "on_violation": "CREATIVE_LOCALE_NOT_ACCEPTED" + } + }, + "spec": "docs/creative/task-reference/sync_creatives.mdx#native-localization" + } + } + ] + } + }, "idempotency_key": { "type": "string", "description": "Client-generated idempotency key for safe retries. If a sync fails without a response, resending with the same idempotency_key guarantees at-most-once execution. MUST be unique per (seller, request) pair to prevent cross-seller correlation. Use a fresh UUID v4 for each request.", @@ -160,8 +272,12 @@ }, "required": [ "idempotency_key", - "account", - "creatives" + "account" + ], + "anyOf": [ + { "required": ["creatives"] }, + { "required": ["assignments"] }, + { "required": ["assignment_operations"] } ], "additionalProperties": true, "examples": [ diff --git a/static/schemas/source/enums/canonical-media-buy-action-mode.json b/static/schemas/source/enums/canonical-media-buy-action-mode.json new file mode 100644 index 0000000000..d8fc84d409 --- /dev/null +++ b/static/schemas/source/enums/canonical-media-buy-action-mode.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/enums/canonical-media-buy-action-mode.json", + "title": "Canonical Media Buy Action Mode", + "description": "How a seller honors a routed compact-lifecycle action.", + "type": "string", + "enum": ["self_serve", "conditional_self_serve", "requires_approval"], + "enumDescriptions": { + "self_serve": "The declared task completes synchronously without approval.", + "conditional_self_serve": "The declared task completes synchronously inside seller tolerances and may become asynchronous outside them.", + "requires_approval": "The declared task uses the standard submitted, working, and input-required lifecycle before terminal completion." + } +} diff --git a/static/schemas/source/enums/canonical-media-buy-action.json b/static/schemas/source/enums/canonical-media-buy-action.json new file mode 100644 index 0000000000..0e2e5c5fef --- /dev/null +++ b/static/schemas/source/enums/canonical-media-buy-action.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/enums/canonical-media-buy-action.json", + "title": "Canonical Media Buy Action Name", + "description": "Fine-grained MediaBuy action vocabulary for compact product and lifecycle surfaces. Deprecated coarse 3.x actions are absent.", + "type": "string", + "enum": [ + "pause", + "resume", + "cancel", + "extend_flight", + "shorten_flight", + "update_flight_dates", + "increase_budget", + "decrease_budget", + "reallocate_budget", + "update_budget_allocation", + "update_targeting", + "update_pacing", + "update_bidding", + "update_frequency_caps", + "update_catalog_assignments", + "update_keywords", + "update_optimization_goals", + "update_impression_goal", + "update_spend_target", + "update_reporting_webhook", + "replace_creative", + "update_creative_assignments", + "remove_creative", + "add_packages", + "remove_packages" + ] +} diff --git a/static/schemas/source/enums/error-code.json b/static/schemas/source/enums/error-code.json index 42c6343cc5..c28d1c9bcf 100644 --- a/static/schemas/source/enums/error-code.json +++ b/static/schemas/source/enums/error-code.json @@ -159,11 +159,11 @@ "PROPOSAL_NOT_COMMITTED": "The referenced legacy get_products proposal has proposal_status 'draft' and cannot be used to create a media buy. Recovery: correctable (finalize it through get_products first). AdCP 3.2 request_proposals and refine_proposals return executable committed snapshots.", "PROPOSAL_NOT_FOUND": "The referenced proposal_id is not recognized by the seller — it belongs to a different tenant, was never issued, or was evicted from the seller's session cache before consumption. Distinct from `PROPOSAL_EXPIRED` (a known proposal whose `expires_at` window has passed) and `PROPOSAL_NOT_COMMITTED` (a known legacy get_products proposal still in `draft`). Recovery: correctable (request a fresh executable proposal, then retry create_media_buy).", "MULTI_FINALIZE_UNSUPPORTED": "Returned by sellers that cannot guarantee atomic commit across multiple proposals in a single `get_products` call (multiple `action: 'finalize'` entries in `refine[]` targeting different `proposal_id` values). The buyer's intent — atomic multi-proposal finalize — is structurally well-formed and per spec atomic, but this seller's downstream stack cannot satisfy the atomicity guarantee (e.g., the proposals route to two different ad servers with no 2PC). More specific than `INVALID_REQUEST` so buyers can distinguish 'this seller doesn't support multi-finalize' from 'the request itself is malformed'. Recovery: correctable (sequence the finalize calls one-`proposal_id`-per-call; there is no capability flag for multi-finalize support, so a successful first-attempt is the only positive discovery signal). See [refinement guide § Finalize is exclusive](/docs/media-buy/product-discovery/refinement#finalize-is-exclusive-within-refine).", - "IO_REQUIRED": "The committed proposal requires a signed insertion order but no io_acceptance was provided. Recovery: correctable (review the proposal's insertion_order, accept terms, and include io_acceptance on create_media_buy).", + "IO_REQUIRED": "The committed proposal requires a signed insertion order but no io_acceptance was provided. Recovery: correctable (review the proposal's insertion_order, accept terms, and include io_acceptance on accept_proposal; 3.x compatibility callers use create_media_buy).", "TERMS_REJECTED": "Buyer-proposed measurement_terms were rejected by the seller. The error details SHOULD identify which specific term was rejected and the seller's acceptable range or supported vendors. Recovery: correctable (adjust the proposed terms and retry, or omit measurement_terms to accept the product's defaults).", "BIDDING_PLACEMENT_CONFLICT": "The authored media-buy/package bidding scopes cannot be represented by the provider's native campaign, package, or shared-strategy placement rules. Sellers MUST detect this before any provider mutation and SHOULD identify the conflicting scopes and provider constraint in error.details. Recovery: correctable (remove or align package overrides, move the policy to the supported scope, or choose a compatible budget mode/product combination).", "AMBIGUOUS_BIDDING_POLICY": "The same effective package combines the canonical bidding block with legacy bid_price or monetary optimization-goal target fields, so two bidding interpretations are present. Sellers MUST reject rather than choosing a winner. Recovery: correctable (emit only the 3.2 bidding block or only one legacy representation).", - "REQUOTE_REQUIRED": "An update_media_buy request changes the parameter envelope (budget, flight dates, volume, targeting) the original quote was priced against. The pricing_option remains locked; the seller is declining the requested shape at that price. Distinct from TERMS_REJECTED (measurement) and POLICY_VIOLATION (content). Sellers SHOULD populate error.details.envelope_field with the field path(s) that breached the envelope (e.g., 'packages[0].budget', 'end_time') so the buyer's agent can decide whether to adjust the update, rediscover products, add packages where supported, or create a separate media buy. AdCP 3.1 does not define an amendment-quote artifact that can be attached to update_media_buy.", + "REQUOTE_REQUIRED": "A control_media_buy request, or the 3.x update_media_buy facade, would exceed the accepted commercial envelope. The seller is declining the requested shape at the current terms. Distinct from TERMS_REJECTED (measurement) and POLICY_VIOLATION (content). Sellers SHOULD populate error.details.envelope_field with the field path(s) that breached the envelope. AdCP 3.2 callers refine the MediaBuy's accepted_proposal_id to obtain a typed amendment; legacy 3.1 callers adjust the update, rediscover terms, or create a separate buy.", "VERSION_UNSUPPORTED": "The declared adcp_version (release-precision) or adcp_major_version (deprecated) is not supported by this seller. The error details SHOULD follow `error-details/version-unsupported.json` — `supported_versions` (release-precision strings) is authoritative for retry; `supported_majors` is deprecated. Recovery: correctable (re-pin to a release in supported_versions and retry; or call get_adcp_capabilities without a version pin to discover supported_versions).", "CAMPAIGN_SUSPENDED": "Campaign governance has been suspended pending human review; the governance agent MUST reject `check_governance` and `report_plan_outcome` calls on the affected plan until the escalation is resolved. Distinct from `ACCOUNT_SUSPENDED` (account-wide) — this is scoped to a single plan/campaign. Recovery: transient (wait for the escalation to resolve; contact the plan operator if the suspension persists).", "GOVERNANCE_UNAVAILABLE": "A registered governance agent is unreachable. Sellers MUST place this code in `errors[]` + `adcp_error` (never a structured rejection arm) and flip transport-level failure markers (HTTP 5xx, MCP `isError: true`, A2A `failed`). Distinct from `GOVERNANCE_DENIED` (agent reachable and explicitly denied — see that code's wire-placement guidance). Recovery: transient (retry with backoff; if the agent remains unreachable, the buyer MUST contact the plan's governance operator — the seller MUST NOT proceed with the media buy without a valid decision).\n\nWire placement (full guidance). Governance unavailability is a system error — the governance call FAILED (timeout, network, config error) and the seller could not get a verdict at all. Always populate both layers per the two-layer model in `error-handling.mdx#envelope-vs-payload-errors-the-two-layer-model`. Do NOT use a structured rejection arm for unavailability even when the task offers one — the buyer's recovery semantics differ (retry-with-backoff for unavailability vs. restructure-or-escalate for denial), and conflating them masks the system-error signal.", @@ -424,7 +424,7 @@ }, "IO_REQUIRED": { "recovery": "correctable", - "suggestion": "review the proposal's insertion_order, accept terms, and include io_acceptance on create_media_buy" + "suggestion": "review the proposal's insertion_order, accept terms, and include io_acceptance on accept_proposal; use create_media_buy only for 3.x compatibility" }, "TERMS_REJECTED": { "recovery": "correctable", @@ -440,7 +440,7 @@ }, "REQUOTE_REQUIRED": { "recovery": "correctable", - "suggestion": "adjust the update to stay within the current quote envelope, rediscover products/terms, add packages when available, or create a separate media buy; 3.1 does not define an amendment-quote artifact for update_media_buy" + "suggestion": "refine accepted_proposal_id into an amendment and apply it through accept_proposal, or keep control_media_buy inside the accepted envelope; 3.1 compatibility callers must adjust or rediscover" }, "VERSION_UNSUPPORTED": { "recovery": "correctable", diff --git a/static/schemas/source/enums/media-buy-action-mode.json b/static/schemas/source/enums/media-buy-action-mode.json index ef7d14090a..18a154b25b 100644 --- a/static/schemas/source/enums/media-buy-action-mode.json +++ b/static/schemas/source/enums/media-buy-action-mode.json @@ -11,8 +11,8 @@ "requires_approval" ], "enumDescriptions": { - "self_serve": "Seller honors the request synchronously without approval or conditional checks. Buyer SDK can dispatch the update_media_buy call and expect an immediate result.", + "self_serve": "Seller honors the request synchronously without approval or conditional checks. Buyer SDKs dispatch the task declared on the available action; legacy entries without task metadata use update_media_buy.", "conditional_self_serve": "Seller auto-approves within declared tolerances and escalates outside them. Covers programmatic guaranteed flows on platforms like FreeWheel, Magnite, and GAM where small mutations clear automatically but large ones queue for human review. Constraint metadata defining the tolerances is out of scope for v1 and the natural home is #4425's `requires` predicate grammar; until #4425 lands, tolerances are declared out-of-band and buyers cannot statically predict which mutations will auto-approve from this surface alone.", - "requires_approval": "Human-in-the-loop, asynchronous, no proposal artifact. Buyer SDK should expect a pending-status poll or webhook callback when the seller's operator approves or rejects." + "requires_approval": "Human-in-the-loop and asynchronous. Buyer SDKs dispatch the task declared on the available action and expect a pending-status poll or webhook callback; compact commercial amendments use refine_proposals and therefore return a proposal artifact." } } diff --git a/static/schemas/source/enums/notification-type.json b/static/schemas/source/enums/notification-type.json index 6f5a9f1768..4c103b98db 100644 --- a/static/schemas/source/enums/notification-type.json +++ b/static/schemas/source/enums/notification-type.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/enums/notification-type.json", "title": "Notification Type", - "description": "Type of push notification fired by a seller agent. Media-buy-anchored notifications (`scheduled`, `final`, `delayed`, `adjusted`, `window_update`, `impairment`) fire against a media buy's `push_notification_config`. Account-anchored notifications (`creative.status_changed`, `creative.purged`, `account.status_changed`, `product.*`, `signal.*`, `wholesale_feed.bulk_change`) fire against an account's `notification_configs[]` entries whose `event_types` include the value — these outlive any single media buy and anchor at the account. Agent-anchored notifications (`capabilities.changed`) fire against the agent-level subscriber set managed by `sync_agent_notification_configs`; they are valid before a buyer has any account. Account status changes use `account.status_changed` as an invalidation signal; receivers repair by re-reading `list_accounts`. Wholesale feed notifications carry the actual change payload in `/schemas/core/wholesale-feed-webhook.json`; receivers use `get_products` / `get_signals` with `if_wholesale_feed_version` to repair or reconcile. Capability-change notifications carry only an invalidation payload in `/schemas/core/capabilities-changed-webhook.json`; receivers repair by re-reading `get_adcp_capabilities`. New notification types added to this enum MUST declare their anchor (media-buy, account, or agent), logical `notification_id` semantics, and repair key in the enumDescription. Sellers MUST reject `notification_configs[]` entries whose `event_types` include any media-buy-anchored or agent-anchored type, MUST reject `sync_agent_notification_configs` entries whose `event_types` include any media-buy-anchored or account-anchored type, and MUST reject `push_notification_config` registrations for persistent account-anchored or agent-anchored types.", + "description": "Type of push notification fired by a seller agent. Media-buy-anchored notifications (`scheduled`, `final`, `delayed`, `adjusted`, `window_update`, `impairment`) fire against a media buy's `push_notification_config`. Account-anchored notifications (`creative.status_changed`, `creative.purged`, `account.status_changed`, `product.*`, `signal.*`, `wholesale_feed.bulk_change`) fire against an account's `notification_configs[]` entries whose `event_types` include the value — these outlive any single media buy and anchor at the account. Agent-anchored notifications (`capabilities.changed`) fire against the agent-level subscriber set managed by `sync_agent_notification_configs`; they are valid before a buyer has any account. Account status changes use `account.status_changed` as an invalidation signal; receivers repair by re-reading `list_accounts`. Wholesale feed notifications carry the actual change payload in `/schemas/core/wholesale-feed-webhook.json`; product mirrors repair through `list_products` using `if_feed_version` and signal mirrors through `get_signals` using `if_wholesale_feed_version` (`get_products` remains the deprecated 3.x product fallback). Capability-change notifications carry only an invalidation payload in `/schemas/core/capabilities-changed-webhook.json`; receivers repair by re-reading `get_adcp_capabilities`. New notification types added to this enum MUST declare their anchor (media-buy, account, or agent), logical `notification_id` semantics, and repair key in the enumDescription. Sellers MUST reject `notification_configs[]` entries whose `event_types` include any media-buy-anchored or agent-anchored type, MUST reject `sync_agent_notification_configs` entries whose `event_types` include any media-buy-anchored or account-anchored type, and MUST reject `push_notification_config` registrations for persistent account-anchored or agent-anchored types.", "type": "string", "enum": [ "scheduled", @@ -43,7 +43,7 @@ "signal.updated": "Sent when signal metadata changes in the seller's wholesale signals feed for the subscriber's account scope. Payload: `wholesale-feed-webhook.json` carrying a `signal.updated` event with the changed signal data when available and advisory `changed_fields[]`. **notification_id**: equals `event.event_id`; re-emissions of the same logical change reuse the same value under a new `idempotency_key`.", "signal.priced": "Sent when signal pricing changes in the seller's wholesale signals feed for the subscriber's account scope. Payload: `wholesale-feed-webhook.json` carrying a `signal.priced` event with the full post-change `pricing_options[]`, optional retired pricing ids, and optional `effective_at`. **notification_id**: equals `event.event_id`; re-emissions of the same logical change reuse the same value under a new `idempotency_key`.", "signal.removed": "Sent when a signal is no longer available in the seller's wholesale signals feed for the subscriber's account scope. Payload: `wholesale-feed-webhook.json` carrying a `signal.removed` event with the signal id, optional removal reason, and cache scope. **notification_id**: equals `event.event_id`; re-emissions of the same logical change reuse the same value under a new `idempotency_key`.", - "wholesale_feed.bulk_change": "Sent when one operation changes too many wholesale product-feed or wholesale signals-feed entities for useful per-entity pushes. Payload: `wholesale-feed-webhook.json` carrying a `wholesale_feed.bulk_change` event with one affected entity type, approximate count, and repair recommendation. Receivers SHOULD re-read the affected feed named by `affected_entity_type` via `get_products` or `get_signals`. **notification_id**: equals `event.event_id`; re-emissions of the same logical change reuse the same value under a new `idempotency_key`.", + "wholesale_feed.bulk_change": "Sent when one operation changes too many wholesale product-feed or wholesale signals-feed entities for useful per-entity pushes. Payload: `wholesale-feed-webhook.json` carrying a `wholesale_feed.bulk_change` event with one affected entity type, approximate count, and repair recommendation. Receivers repair products through `list_products` (or deprecated 3.x `get_products`) and signals through `get_signals`. **notification_id**: equals `event.event_id`; re-emissions of the same logical change reuse the same value under a new `idempotency_key`.", "capabilities.changed": "Agent-anchored fire. Sent when the seller's advertised `get_adcp_capabilities` document materially changes. Fires per subscriber against each `sync_agent_notification_configs.notification_configs[]` entry whose `event_types` includes this value. Payload: `capabilities-changed-webhook.json`. The payload does not include the full capability document; receivers SHOULD re-run `get_adcp_capabilities`, compare `adcp.capability_changes.capabilities_version` or `last_modified` when present, and update their cache from that fresh response. **notification_id**: stable per material capability revision; re-emissions of the same revision reuse the id, and a later material revision receives a new id." } } diff --git a/static/schemas/source/enums/proposal-status.json b/static/schemas/source/enums/proposal-status.json index f3abc730ca..a467aeba03 100644 --- a/static/schemas/source/enums/proposal-status.json +++ b/static/schemas/source/enums/proposal-status.json @@ -2,14 +2,16 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/enums/proposal-status.json", "title": "Proposal Status", - "description": "Lifecycle status of a proposal. This is the per-proposal signal for whether finalization is required before create_media_buy. Absent means the proposal is ready to buy (backward compatible with pre-v3.1 proposals). 'draft' indicates indicative pricing that must be finalized before purchase. 'committed' indicates firm terms that can be executed until expires_at; the status alone does not assert an inventory hold. Legacy get_products finalization requests a hold, while split 3.2 proposal tasks may return executable alternatives without reserving every alternative.", + "description": "Lifecycle status of an immutable commercial snapshot. 'draft' is indicative, 'committed' is a firm offer awaiting buyer acceptance, and 'accepted' is the historical terms snapshot attached to a MediaBuy. Refining an accepted snapshot creates a new proposal ID; it never mutates or reopens the accepted snapshot.", "type": "string", "enum": [ "draft", - "committed" + "committed", + "accepted" ], "enumDescriptions": { - "draft": "Indicative pricing and availability. The buyer can compare and plan but must finalize before purchasing. Use the 'finalize' refine action to request firm pricing and any inventory hold.", - "committed": "Firm, executable terms. The buyer can execute this proposal via create_media_buy before expires_at. The status does not by itself promise that inventory is reserved; a seller that provides a hold must state it in the proposal terms. Execution is buyer acceptance. After expires_at, the buyer must request or discover a fresh proposal." + "draft": "Indicative pricing and availability. The buyer can compare and plan but must request a committed revision before purchasing. The legacy get_products facade may use its 'finalize' refine action; refine_proposals returns committed revisions directly.", + "committed": "Firm, executable terms. The buyer can accept this proposal before expires_at. The status does not by itself promise that inventory is reserved; a seller that provides a hold must state it in the proposal terms.", + "accepted": "Immutable historical commercial terms attached to a MediaBuy. The buyer may pass this proposal_id to refine_proposals to fork an amendment or cancellation proposal." } } diff --git a/static/schemas/source/enums/task-type.json b/static/schemas/source/enums/task-type.json index 80fa926223..8edb16efc8 100644 --- a/static/schemas/source/enums/task-type.json +++ b/static/schemas/source/enums/task-type.json @@ -7,6 +7,9 @@ "enum": [ "create_media_buy", "update_media_buy", + "buy_products", + "accept_proposal", + "control_media_buy", "media_buy_delivery", "sync_creatives", "build_creative", @@ -39,6 +42,9 @@ "enumDescriptions": { "create_media_buy": "Media-buy domain: Create a new advertising campaign with one or more packages", "update_media_buy": "Media-buy domain: Update campaign settings, package configuration, or delivery parameters", + "buy_products": "Media-buy domain: Create a MediaBuy directly from published product offers without inline creatives", + "accept_proposal": "Media-buy domain: Accept a committed proposal to create, amend, or cancel a MediaBuy", + "control_media_buy": "Media-buy domain: Apply operational delivery controls inside accepted commercial terms", "media_buy_delivery": "Media-buy domain: Persistent delivery-report webhook event emitted for a configured reporting_webhook. The inner result uses media-buy-delivery-webhook-result.json.", "sync_creatives": "Media-buy domain: Sync creative assets to publisher's library with upsert semantics", "build_creative": "Creative domain: Generate, transform, refine, or retrieve a creative manifest", diff --git a/static/schemas/source/governance/check-governance-request.json b/static/schemas/source/governance/check-governance-request.json index 8e6a88c451..076f29c522 100644 --- a/static/schemas/source/governance/check-governance-request.json +++ b/static/schemas/source/governance/check-governance-request.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/governance/check-governance-request.json", "title": "Check Governance Request", - "description": "Universal governance check for campaign actions. The governance agent infers the check type from the fields present: tool+payload = intent check (proposed, orchestrator-side); planned_delivery or delivery_metrics with governance_context = execution or lifecycle check (committed, service-side). For update_media_buy, the buyer supplies a proposed positive-delta ceiling and the seller supplies its independently computed execution delta. The first check is addressed by plan_id. Subsequent service-side checks use the opaque governance_context as the authoritative plan binding and do not require the service to receive or interpret plan_id. To check budget availability without a specific action, send plan_id and omit tool and payload.", + "description": "Universal governance check for campaign actions. The governance agent infers the check type from the fields present: tool+payload = intent check (proposed, orchestrator-side); planned_delivery or delivery_metrics with governance_context = execution or lifecycle check (committed, service-side). Proposal acceptance supplies the immutable proposal separately so governance can inspect its typed commercial terms while payload remains the exact downstream arguments. MediaBuy controls use buyer-proposed and seller-computed positive-delta ceilings. The first check is addressed by plan_id. Subsequent service-side checks use the opaque governance_context as the authoritative plan binding.", "type": "object", "allOf": [ { @@ -64,6 +64,13 @@ } } }, + { + "if": { "required": ["proposal"] }, + "then": { + "required": ["plan_id", "tool", "payload", "target_agent"], + "properties": { "tool": { "const": "accept_proposal" } } + } + }, { "if": { "required": ["execution_commitment"] }, "then": { @@ -80,12 +87,19 @@ { "if": { "properties": { - "tool": { "enum": ["update_media_buy", "acquire_rights", "update_rights", "activate_signal", "build_creative"] } + "tool": { "enum": ["update_media_buy", "buy_products", "accept_proposal", "control_media_buy", "acquire_rights", "update_rights", "activate_signal", "build_creative"] } }, "required": ["tool", "payload"] }, "then": { "required": ["proposed_commitment"] } }, + { + "if": { + "properties": { "tool": { "const": "accept_proposal" } }, + "required": ["tool", "payload"] + }, + "then": { "required": ["proposal"] } + }, { "if": { "anyOf": [ @@ -213,7 +227,7 @@ }, "proposed_commitment": { "type": "object", - "description": "Task-neutral monetary amount the intent would authorize. For update_media_buy this is the buyer-computed positive incremental commitment, not the post-update total. It is also required for acquire_rights, commitment-increasing update_rights, activate_signal, and build_creative because those task payloads identify pricing indirectly; amount 0 explicitly represents a verified no-cost action. The governance agent persists this value as authoritative check state; report_plan_outcome cannot increase it or restore headroom by reporting less.", + "description": "Task-neutral monetary amount the intent would authorize. For update_media_buy and control_media_buy this is the buyer-computed positive incremental commitment, not the post-update total. For accept_proposal it is derived from the supplied proposal commercial_terms; for buy_products it is derived from the purchase payload. Amount 0 explicitly represents a verified no-cost action. The governance agent persists this value as authoritative check state.", "properties": { "amount": { "type": "number", "minimum": 0 }, "currency": { "type": "string", "pattern": "^[A-Z]{3}$" } @@ -223,7 +237,7 @@ }, "execution_commitment": { "type": "object", - "description": "Seller-computed positive incremental commitment for an update_media_buy execution check. The seller MUST derive this atomically from its authoritative current revision and the requested update, and the governance agent MUST reject it when it exceeds the prior intent ceiling or uses another currency.", + "description": "Seller-computed positive incremental commitment for a MediaBuy execution check. The seller MUST derive this atomically from its authoritative proposal or current revision and the requested operation, and the governance agent MUST reject it when it exceeds the prior intent ceiling or uses another currency.", "properties": { "amount": { "type": "number", "minimum": 0 }, "currency": { "type": "string", "pattern": "^[A-Z]{3}$" } @@ -241,6 +255,10 @@ "x-adcp-open-payload": true, "additionalProperties": true }, + "proposal": { + "$ref": "/schemas/core/canonical-proposal.json", + "description": "Exact committed proposal being authorized for accept_proposal. The governance agent verifies proposal.terms_digest against commercial_terms and binds that digest into its decision state; the downstream payload carries the same digest without repeating the terms." + }, "governance_context": { "type": "string", "description": "Opaque authorization context from a prior approved check_governance response. Services pass it verbatim on execution and lifecycle checks; the issuing governance agent derives the plan and prior decision from the token. Intermediaries MUST NOT parse it for business logic. Governance agents MUST emit a compact JWS per the AdCP JWS profile.", diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index c1c8627ea7..19559db3a6 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -49,6 +49,62 @@ "$ref": "/schemas/core/canonical-product.json", "description": "Canonical-only product view for the AdCP 3.2 split product and proposal tools" }, + "canonical-format-option": { + "$ref": "/schemas/core/canonical-format-option.json", + "description": "Compact canonical format declaration without legacy named-format links" + }, + "canonical-placement": { + "$ref": "/schemas/core/canonical-placement.json", + "description": "Compact canonical product placement" + }, + "canonical-product-action": { + "$ref": "/schemas/core/canonical-product-action.json", + "description": "Fine-grained action template for compact products" + }, + "canonical-proposal": { + "$ref": "/schemas/core/canonical-proposal.json", + "description": "Compact immutable proposal with a typed commercial envelope" + }, + "canonical-account-ref": { + "$ref": "/schemas/core/canonical-account-ref.json", + "description": "Compact account identity without inline brand documents" + }, + "canonical-budget-allocation": { + "$ref": "/schemas/core/canonical-budget-allocation.json", + "description": "Compact budget allocation for canonical MediaBuy tools" + }, + "canonical-optimization-goal": { + "$ref": "/schemas/core/canonical-optimization-goal.json", + "description": "Compact optimization goal without legacy targets or inline vendor brands" + }, + "canonical-metric-qualifier": { + "$ref": "/schemas/core/canonical-metric-qualifier.json", + "description": "Compact reporting metric qualifier" + }, + "canonical-reporting-commitment": { + "$ref": "/schemas/core/canonical-reporting-commitment.json", + "description": "Compact standard or vendor reporting commitment" + }, + "canonical-media-buy-action": { + "$ref": "/schemas/core/canonical-media-buy-action.json", + "description": "Available MediaBuy action routed to its compact-lifecycle task" + }, + "keyword-target": { + "$ref": "/schemas/core/keyword-target.json", + "description": "Compact keyword targeting mutation" + }, + "compact-task-submitted": { + "$ref": "/schemas/core/compact-task-submitted.json", + "description": "Shared submitted envelope for compact lifecycle tools" + }, + "compact-task-working": { + "$ref": "/schemas/core/compact-task-working.json", + "description": "Shared progress payload for compact lifecycle tools" + }, + "compact-task-input-required": { + "$ref": "/schemas/core/compact-task-input-required.json", + "description": "Shared input-required payload for compact lifecycle tools" + }, "media-buy": { "$ref": "/schemas/core/media-buy.json", "description": "Represents a purchased advertising campaign" @@ -841,6 +897,14 @@ "$ref": "/schemas/enums/media-buy-status.json", "description": "Status of a media buy" }, + "canonical-media-buy-action": { + "$ref": "/schemas/enums/canonical-media-buy-action.json", + "description": "Fine-grained action vocabulary for compact MediaBuy tools" + }, + "canonical-media-buy-action-mode": { + "$ref": "/schemas/enums/canonical-media-buy-action-mode.json", + "description": "Execution mode for routed compact-lifecycle actions" + }, "creative-status": { "$ref": "/schemas/enums/creative-status.json", "description": "Status of a creative asset" @@ -1331,6 +1395,22 @@ "$ref": "/schemas/media-buy/proposal-decline.json", "description": "One terminal decline of an immutable proposal" }, + "product-purchase": { + "$ref": "/schemas/media-buy/product-purchase.json", + "description": "Canonical direct product selection without creatives or negotiated term overrides" + }, + "commercial-terms": { + "$ref": "/schemas/media-buy/commercial-terms.json", + "description": "Typed immutable commercial envelope shared by direct purchases and proposals" + }, + "package-control": { + "$ref": "/schemas/media-buy/package-control.json", + "description": "Operational package controls bounded by accepted commercial terms" + }, + "media-buy-commitment-response": { + "$ref": "/schemas/media-buy/media-buy-commitment-response.json", + "description": "Compact shared result for direct purchase and proposal acceptance" + }, "get-products-rejected": { "$ref": "/schemas/media-buy/get-products-rejected.json", "description": "Terminal business rejection arm for a well-formed get_products brief or refinement" @@ -1397,6 +1477,36 @@ "description": "Ordered decline results for decline_proposals" } }, + "buy-products": { + "request": { + "$ref": "/schemas/media-buy/buy-products-request.json", + "description": "Create a MediaBuy directly from canonical published product offers" + }, + "response": { + "$ref": "/schemas/media-buy/buy-products-response.json", + "description": "Compact MediaBuy commitment and accepted commercial snapshot" + } + }, + "accept-proposal": { + "request": { + "$ref": "/schemas/media-buy/accept-proposal-request.json", + "description": "Accept a committed new-buy, amendment, or cancellation proposal" + }, + "response": { + "$ref": "/schemas/media-buy/accept-proposal-response.json", + "description": "Compact MediaBuy commitment and accepted commercial snapshot" + } + }, + "control-media-buy": { + "request": { + "$ref": "/schemas/media-buy/control-media-buy-request.json", + "description": "Apply operational controls inside accepted commercial terms" + }, + "response": { + "$ref": "/schemas/media-buy/control-media-buy-response.json", + "description": "Compact operational-control result" + } + }, "list-creative-formats": { "request": { "$ref": "/schemas/media-buy/list-creative-formats-request.json", @@ -1412,21 +1522,25 @@ "create-media-buy": { "request": { "$ref": "/schemas/media-buy/create-media-buy-request.json", - "description": "Request parameters for creating a media buy" + "deprecated": true, + "description": "AdCP 3.x compatibility request. New 3.2 callers use buy_products or accept_proposal." }, "response": { "$ref": "/schemas/media-buy/create-media-buy-response.json", - "description": "Response payload for create_media_buy task" + "deprecated": true, + "description": "AdCP 3.x compatibility response for create_media_buy" } }, "update-media-buy": { "request": { "$ref": "/schemas/media-buy/update-media-buy-request.json", - "description": "Request parameters for updating campaign and package settings" + "deprecated": true, + "description": "AdCP 3.x compatibility request. New 3.2 callers use control_media_buy or refine_proposals." }, "response": { "$ref": "/schemas/media-buy/update-media-buy-response.json", - "description": "Response payload for update_media_buy task" + "deprecated": true, + "description": "AdCP 3.x compatibility response for update_media_buy" } }, "get-media-buys": { @@ -2206,6 +2320,12 @@ }, "compliance": { "description": "Compliance testing tool schemas. The test controller is an optional sandbox-only tool that lets comply walk full lifecycle state machines by triggering seller-side transitions deterministically.", + "supporting-schemas": { + "task-completion-data": { + "$ref": "/schemas/compliance/task-completion-data.json", + "description": "Bounded force_task_completion result union for supported legacy async scenarios" + } + }, "tasks": { "comply-test-controller": { "request": { diff --git a/static/schemas/source/media-buy/accept-proposal-async-response-input-required.json b/static/schemas/source/media-buy/accept-proposal-async-response-input-required.json new file mode 100644 index 0000000000..e9d5056459 --- /dev/null +++ b/static/schemas/source/media-buy/accept-proposal-async-response-input-required.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/accept-proposal-async-response-input-required.json", + "title": "Accept Proposal - Input Required", + "description": "Bounded input-required payload for accept_proposal.", + "allOf": [ + { "$ref": "/schemas/core/compact-task-input-required.json" } + ] +} diff --git a/static/schemas/source/media-buy/accept-proposal-async-response-submitted.json b/static/schemas/source/media-buy/accept-proposal-async-response-submitted.json new file mode 100644 index 0000000000..b7ac9a7cfa --- /dev/null +++ b/static/schemas/source/media-buy/accept-proposal-async-response-submitted.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/accept-proposal-async-response-submitted.json", + "title": "Accept Proposal - Submitted", + "description": "Bounded submitted task envelope for accept_proposal.", + "allOf": [ + { "$ref": "/schemas/core/compact-task-submitted.json" } + ] +} diff --git a/static/schemas/source/media-buy/accept-proposal-async-response-working.json b/static/schemas/source/media-buy/accept-proposal-async-response-working.json new file mode 100644 index 0000000000..12451074a8 --- /dev/null +++ b/static/schemas/source/media-buy/accept-proposal-async-response-working.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/accept-proposal-async-response-working.json", + "title": "Accept Proposal - Working", + "description": "Bounded working progress payload for accept_proposal.", + "allOf": [ + { "$ref": "/schemas/core/compact-task-working.json" } + ] +} diff --git a/static/schemas/source/media-buy/accept-proposal-request.json b/static/schemas/source/media-buy/accept-proposal-request.json new file mode 100644 index 0000000000..ccbf7d298b --- /dev/null +++ b/static/schemas/source/media-buy/accept-proposal-request.json @@ -0,0 +1,109 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/accept-proposal-request.json", + "title": "Accept Proposal Request", + "description": "Accept one committed immutable proposal. Depending on proposal_kind, acceptance creates a MediaBuy, applies an amendment, or applies a negotiated cancellation. The proposal already contains the commercial terms, so callers do not repeat packages, dates, targeting, or creatives.", + "type": "object", + "x-mutates-state": true, + "x-governed-commitment": { + "scope": "conditional", + "triggers": ["creates_commitment", "increases_obligation", "extends_term", "resumes_execution", "reallocates_delivery"], + "exemptions": ["negotiated_cancellation", "decrease_only"], + "precedence": "trigger_overrides_exemption" + }, + "x-operation-family": "accept_proposal", + "x-added-in": "3.2.0", + "x-legacy-fallback": { "tool": "create_media_buy", "mode": "orchestrated" }, + "properties": { + "adcp_version": { + "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" + }, + "idempotency_key": { + "type": "string", + "minLength": 16, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{16,255}$" + }, + "account": { + "$ref": "/schemas/core/canonical-account-ref.json" + }, + "proposal_id": { + "type": "string", + "minLength": 1, + "x-entity": "proposal" + }, + "proposal_terms_digest": { + "type": "string", + "pattern": "^sha256:[A-Za-z0-9_-]{43}$", + "description": "terms_digest from the committed proposal. The seller MUST atomically verify both ID and digest before acceptance." + }, + "total_budget": { + "type": "object", + "description": "Execution amount when the committed proposal defines scalable percentages or constraints rather than a fixed total.", + "properties": { + "amount": { "type": "number", "minimum": 0 }, + "currency": { "type": "string", "pattern": "^[A-Z]{3}$" } + }, + "required": ["amount", "currency"], + "additionalProperties": false + }, + "io_acceptance": { + "type": "object", + "properties": { + "io_id": { "type": "string", "minLength": 1 }, + "accepted_at": { "type": "string", "format": "date-time" }, + "signatory": { "type": "string", "minLength": 1, "maxLength": 250 }, + "signature_id": { "type": "string", "minLength": 1 } + }, + "required": ["io_id", "accepted_at", "signatory"], + "additionalProperties": false + }, + "purchase_order_ref": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "governance_context": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "push_notification_config": { + "$ref": "/schemas/core/push-notification-config.json" + }, + "reporting_webhook": { + "$ref": "/schemas/core/reporting-webhook.json", + "description": "Optional reporting delivery configuration established atomically when the proposal is accepted. This is execution metadata and does not alter the accepted commercial terms digest." + }, + "opportunity": { + "allOf": [ + { "$ref": "/schemas/core/opportunity-context.json" }, + { + "properties": { "status": { "const": "closed" } }, + "if": { + "properties": { "status": { "const": "closed" } }, + "required": ["status"] + }, + "then": { + "properties": { "close_reason": { "const": "accepted_with_seller" } } + } + } + ], + "description": "Optional planning-cycle closure. Success infers closed with accepted_with_seller when status is omitted. If the proposal carries opportunity_id, a supplied ID MUST match; the accepted proposal preserves that association." + }, + "context": { + "$ref": "/schemas/core/context.json" + }, + "ext": { "$ref": "/schemas/core/ext.json" } + }, + "required": ["idempotency_key", "account", "proposal_id", "proposal_terms_digest"], + "additionalProperties": false, + "not": { + "anyOf": [ + { "required": ["packages"] }, + { "required": ["purchases"] }, + { "required": ["creatives"] }, + { "required": ["creative_assignments"] } + ] + } +} diff --git a/static/schemas/source/media-buy/accept-proposal-response.json b/static/schemas/source/media-buy/accept-proposal-response.json new file mode 100644 index 0000000000..f332a56a5a --- /dev/null +++ b/static/schemas/source/media-buy/accept-proposal-response.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/accept-proposal-response.json", + "title": "Accept Proposal Response", + "description": "Compact proposal-acceptance result containing the resulting MediaBuy identity and accepted immutable proposal snapshot.", + "allOf": [ + { "$ref": "/schemas/media-buy/media-buy-commitment-response.json" } + ] +} diff --git a/static/schemas/source/media-buy/buy-products-async-response-input-required.json b/static/schemas/source/media-buy/buy-products-async-response-input-required.json new file mode 100644 index 0000000000..f1681e3884 --- /dev/null +++ b/static/schemas/source/media-buy/buy-products-async-response-input-required.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/buy-products-async-response-input-required.json", + "title": "Buy Products - Input Required", + "description": "Bounded input-required payload for buy_products.", + "allOf": [ + { "$ref": "/schemas/core/compact-task-input-required.json" } + ] +} diff --git a/static/schemas/source/media-buy/buy-products-async-response-submitted.json b/static/schemas/source/media-buy/buy-products-async-response-submitted.json new file mode 100644 index 0000000000..1890d52dd3 --- /dev/null +++ b/static/schemas/source/media-buy/buy-products-async-response-submitted.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/buy-products-async-response-submitted.json", + "title": "Buy Products - Submitted", + "description": "Bounded submitted task envelope for buy_products.", + "allOf": [ + { "$ref": "/schemas/core/compact-task-submitted.json" } + ] +} diff --git a/static/schemas/source/media-buy/buy-products-async-response-working.json b/static/schemas/source/media-buy/buy-products-async-response-working.json new file mode 100644 index 0000000000..8aeb4b23f8 --- /dev/null +++ b/static/schemas/source/media-buy/buy-products-async-response-working.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/buy-products-async-response-working.json", + "title": "Buy Products - Working", + "description": "Bounded working progress payload for buy_products.", + "allOf": [ + { "$ref": "/schemas/core/compact-task-working.json" } + ] +} diff --git a/static/schemas/source/media-buy/buy-products-request.json b/static/schemas/source/media-buy/buy-products-request.json new file mode 100644 index 0000000000..8bb2276b72 --- /dev/null +++ b/static/schemas/source/media-buy/buy-products-request.json @@ -0,0 +1,152 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/buy-products-request.json", + "title": "Buy Products Request", + "description": "Create a MediaBuy directly from published product offers. This buyer-composed path accepts published commercial terms, supports targeting and delivery controls, and never accepts inline creatives or creative assignments. The seller records an immutable accepted proposal snapshot so later commercial amendments can use refine_proposals.", + "type": "object", + "x-mutates-state": true, + "x-governed-commitment": { "scope": "always" }, + "x-operation-family": "buy_products", + "x-added-in": "3.2.0", + "x-legacy-fallback": { "tool": "create_media_buy", "mode": "orchestrated" }, + "properties": { + "adcp_version": { + "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" + }, + "idempotency_key": { + "type": "string", + "minLength": 16, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{16,255}$" + }, + "account": { + "allOf": [{ "$ref": "/schemas/core/canonical-account-ref.json" }], + "description": "Execution account. A natural-key account is the single brand source and MUST NOT be combined with top-level brand." + }, + "brand": { + "$ref": "/schemas/core/brand-key.json" + }, + "advertiser_industry": { + "$ref": "/schemas/enums/advertiser-industry.json", + "description": "Industry classification for this campaign. Sellers may infer it from the resolved brand manifest when omitted." + }, + "feed_version": { + "type": "string", + "minLength": 1, + "description": "list_products feed_version containing the published offers being accepted. Sellers reject stale or mismatched versions rather than silently applying changed terms." + }, + "pricing_version": { + "type": "string", + "minLength": 1, + "description": "list_products pricing_version containing the accepted rate. Buyers MUST include this whenever list_products returned one; omission means the seller does not version pricing separately." + }, + "purchases": { + "type": "array", + "items": { "$ref": "/schemas/media-buy/product-purchase.json" }, + "minItems": 1 + }, + "total_budget": { + "type": "object", + "properties": { + "amount": { "type": "number", "minimum": 0 }, + "currency": { "type": "string", "pattern": "^[A-Z]{3}$" } + }, + "required": ["amount", "currency"], + "additionalProperties": false + }, + "budget_allocation": { + "$ref": "/schemas/core/canonical-budget-allocation.json" + }, + "start_time": { + "$ref": "/schemas/core/start-timing.json" + }, + "end_time": { + "type": "string", + "format": "date-time" + }, + "pacing": { + "$ref": "/schemas/enums/pacing.json" + }, + "bidding": { + "$ref": "/schemas/core/bidding-policy.json" + }, + "paused": { + "type": "boolean", + "default": false + }, + "purchase_order_ref": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "agency_estimate_number": { + "type": "string", + "maxLength": 100 + }, + "invoice_recipient": { + "$ref": "/schemas/core/business-entity.json", + "description": "Authorized per-buy billing entity override." + }, + "governance_context": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "push_notification_config": { + "$ref": "/schemas/core/push-notification-config.json" + }, + "reporting_webhook": { + "$ref": "/schemas/core/reporting-webhook.json", + "description": "Optional reporting delivery configuration established atomically with the MediaBuy. This is execution metadata and is not part of the immutable product pricing terms." + }, + "opportunity": { + "allOf": [ + { "$ref": "/schemas/core/opportunity-context.json" }, + { + "properties": { "status": { "const": "closed" } }, + "if": { + "properties": { "status": { "const": "closed" } }, + "required": ["status"] + }, + "then": { + "properties": { "close_reason": { "const": "accepted_with_seller" } } + } + } + ], + "description": "Optional planning-cycle closure for a direct product purchase. Success infers closed with accepted_with_seller when status is omitted; an explicit status MUST carry that same closure." + }, + "context": { + "$ref": "/schemas/core/context.json" + }, + "ext": { "$ref": "/schemas/core/ext.json" } + }, + "required": ["idempotency_key", "account", "feed_version", "purchases", "start_time", "end_time"], + "anyOf": [ + { "required": ["brand"] }, + { + "properties": { + "account": { "required": ["brand", "operator"] } + } + } + ], + "allOf": [ + { + "not": { + "properties": { + "account": { "required": ["brand", "operator"] } + }, + "required": ["account", "brand"] + } + } + ], + "additionalProperties": false, + "not": { + "anyOf": [ + { "required": ["packages"] }, + { "required": ["proposal_id"] }, + { "required": ["creatives"] }, + { "required": ["creative_assignments"] }, + { "required": ["artifact_webhook"] } + ] + } +} diff --git a/static/schemas/source/media-buy/buy-products-response.json b/static/schemas/source/media-buy/buy-products-response.json new file mode 100644 index 0000000000..85cdb9fe00 --- /dev/null +++ b/static/schemas/source/media-buy/buy-products-response.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/buy-products-response.json", + "title": "Buy Products Response", + "description": "Compact direct-purchase result containing the MediaBuy identity and accepted immutable proposal snapshot.", + "allOf": [ + { "$ref": "/schemas/media-buy/media-buy-commitment-response.json" } + ] +} diff --git a/static/schemas/source/media-buy/commercial-terms.json b/static/schemas/source/media-buy/commercial-terms.json new file mode 100644 index 0000000000..7238aa2684 --- /dev/null +++ b/static/schemas/source/media-buy/commercial-terms.json @@ -0,0 +1,103 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/commercial-terms.json", + "title": "Commercial Terms", + "description": "Complete typed commercial envelope for a compact-lifecycle proposal. This is the authoritative audit and refinement snapshot; allocations and narrative fields are explanatory views rather than substitutes for these terms.", + "type": "object", + "x-adcp-validation": { + "verifier_constraints": { + "pricing_integrity": { + "pricing_option_ids": "each_purchase.pricing_option_id_equals_pricing.pricing_option_id", + "purchase_currencies": "all_purchase.pricing.currency_equal", + "total_budget_currency": "when_total_budget_present_equals_purchase_pricing_currency", + "monetary_fields": "purchase_budget_min_spend_and_bidding_use_purchase_pricing_currency", + "on_violation": "reject_proposal_or_commitment" + } + } + }, + "properties": { + "source_feed_version": { + "type": "string", + "minLength": 1, + "description": "Wholesale product feed version against which direct published offers were accepted. Omitted when the seller authored terms outside a wholesale snapshot." + }, + "source_pricing_version": { + "type": "string", + "minLength": 1, + "description": "Pricing-layer version against which published rates were accepted." + }, + "brand": { "$ref": "/schemas/core/brand-key.json" }, + "advertiser_industry": { "$ref": "/schemas/enums/advertiser-industry.json" }, + "purchases": { + "type": "array", + "items": { + "allOf": [ + { "$ref": "/schemas/media-buy/product-purchase.json" }, + { "required": ["product_id", "pricing_option_id", "pricing", "start_time", "end_time"] } + ] + }, + "minItems": 1, + "description": "Exact canonical product, pricing, format, catalog, budget, targeting, bidding, optimization, resolved flight, measurement, and performance terms in the commercial envelope.", + "x-adcp-validation": { + "verifier_constraints": { + "resolved_purchase_terms": "Each purchase carries resolved start_time and end_time. Applicable measurement_terms and performance_standards are copied from the published offer or negotiated successor; omission means no such terms apply." + } + } + }, + "start_time": { "$ref": "/schemas/core/start-timing.json" }, + "end_time": { "type": "string", "format": "date-time" }, + "total_budget": { + "type": "object", + "properties": { + "amount": { "type": "number", "minimum": 0 }, + "currency": { "type": "string", "pattern": "^[A-Z]{3}$" } + }, + "required": ["amount", "currency"], + "additionalProperties": false + }, + "budget_allocation": { "$ref": "/schemas/core/canonical-budget-allocation.json" }, + "pacing": { "$ref": "/schemas/enums/pacing.json" }, + "bidding": { "$ref": "/schemas/core/bidding-policy.json" }, + "invoice_recipient": { "$ref": "/schemas/core/business-entity.json" }, + "purchase_order_ref": { "type": "string", "minLength": 1, "maxLength": 255 }, + "agency_estimate_number": { "type": "string", "maxLength": 100 }, + "reporting_commitments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "purchase_index": { "type": "integer", "minimum": 0 }, + "metrics": { + "type": "array", + "items": { "$ref": "/schemas/core/canonical-reporting-commitment.json" }, + "minItems": 1 + } + }, + "required": ["purchase_index", "metrics"], + "additionalProperties": false + }, + "minItems": 1, + "description": "Binding reporting contract keyed by position in purchases. Amendments preserve prior entries and add metrics with effective_at; seller-assigned package IDs live in the execution binding, outside this digest." + }, + "cancellation_terms": { + "type": "object", + "properties": { + "effective_at": { "type": "string", "format": "date-time" }, + "fee": { + "type": "object", + "properties": { + "amount": { "type": "number", "minimum": 0 }, + "currency": { "type": "string", "pattern": "^[A-Z]{3}$" } + }, + "required": ["amount", "currency"], + "additionalProperties": false + }, + "reason": { "type": "string", "minLength": 1, "maxLength": 500 } + }, + "required": ["effective_at"], + "additionalProperties": false + } + }, + "required": ["brand", "purchases", "start_time", "end_time"], + "additionalProperties": false +} diff --git a/static/schemas/source/media-buy/control-media-buy-async-response-input-required.json b/static/schemas/source/media-buy/control-media-buy-async-response-input-required.json new file mode 100644 index 0000000000..a2a2818416 --- /dev/null +++ b/static/schemas/source/media-buy/control-media-buy-async-response-input-required.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/control-media-buy-async-response-input-required.json", + "title": "Control Media Buy - Input Required", + "description": "Bounded input-required payload for control_media_buy.", + "allOf": [ + { "$ref": "/schemas/core/compact-task-input-required.json" } + ] +} diff --git a/static/schemas/source/media-buy/control-media-buy-async-response-submitted.json b/static/schemas/source/media-buy/control-media-buy-async-response-submitted.json new file mode 100644 index 0000000000..a9afeb0ae1 --- /dev/null +++ b/static/schemas/source/media-buy/control-media-buy-async-response-submitted.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/control-media-buy-async-response-submitted.json", + "title": "Control Media Buy - Submitted", + "description": "Bounded submitted task envelope for control_media_buy.", + "allOf": [ + { "$ref": "/schemas/core/compact-task-submitted.json" } + ] +} diff --git a/static/schemas/source/media-buy/control-media-buy-async-response-working.json b/static/schemas/source/media-buy/control-media-buy-async-response-working.json new file mode 100644 index 0000000000..2cdbc351a3 --- /dev/null +++ b/static/schemas/source/media-buy/control-media-buy-async-response-working.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/control-media-buy-async-response-working.json", + "title": "Control Media Buy - Working", + "description": "Bounded working progress payload for control_media_buy.", + "allOf": [ + { "$ref": "/schemas/core/compact-task-working.json" } + ] +} diff --git a/static/schemas/source/media-buy/control-media-buy-request.json b/static/schemas/source/media-buy/control-media-buy-request.json new file mode 100644 index 0000000000..5430711c09 --- /dev/null +++ b/static/schemas/source/media-buy/control-media-buy-request.json @@ -0,0 +1,148 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/control-media-buy-request.json", + "title": "Control Media Buy Request", + "description": "Apply operational delivery controls inside the MediaBuy's accepted proposal envelope. Sellers return REQUOTE_REQUIRED when budget, targeting, or another control would change the commercial envelope; the buyer then forks the accepted proposal through refine_proposals. Creative mutation, new products/packages, flight changes, pricing changes, and billing-term changes are not accepted here.", + "type": "object", + "x-mutates-state": true, + "x-governed-commitment": { + "scope": "conditional", + "triggers": ["increases_obligation", "resumes_execution", "reallocates_delivery"], + "exemptions": ["cancel", "pause", "decrease_only"], + "precedence": "trigger_overrides_exemption" + }, + "x-operation-family": "control_media_buy", + "x-added-in": "3.2.0", + "x-legacy-fallback": { "tool": "update_media_buy", "mode": "orchestrated" }, + "properties": { + "adcp_version": { + "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" + }, + "idempotency_key": { + "type": "string", + "minLength": 16, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{16,255}$" + }, + "account": { + "$ref": "/schemas/core/canonical-account-ref.json" + }, + "media_buy_id": { + "type": "string", + "minLength": 1, + "x-entity": "media_buy" + }, + "revision": { + "type": "integer", + "minimum": 1, + "description": "Required optimistic-concurrency revision from the latest MediaBuy snapshot." + }, + "paused": { + "type": "boolean" + }, + "canceled": { + "type": "boolean", + "const": true, + "description": "Exercise an already-accepted unilateral cancellation right. A cancellation requiring seller agreement is requested by refining the accepted proposal." + }, + "cancellation_reason": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "total_budget": { + "type": "object", + "properties": { + "amount": { "type": "number", "minimum": 0 }, + "currency": { "type": "string", "pattern": "^[A-Z]{3}$" } + }, + "required": ["amount", "currency"], + "additionalProperties": false + }, + "budget_allocation": { + "$ref": "/schemas/core/canonical-budget-allocation.json" + }, + "pacing": { + "$ref": "/schemas/enums/pacing.json" + }, + "bidding": { + "anyOf": [ + { "$ref": "/schemas/core/bidding-policy.json" }, + { "type": "null" } + ] + }, + "packages": { + "type": "array", + "description": "Operational patches keyed by package_id. Each package_id MUST appear at most once; sellers reject duplicate IDs atomically.", + "x-adcp-validation": { + "verifier_constraints": { + "unique_package_ids": { + "key": "package_id", + "on_violation": "reject_before_mutation" + } + } + }, + "items": { "$ref": "/schemas/media-buy/package-control.json" }, + "minItems": 1 + }, + "reporting_webhook": { + "$ref": "/schemas/core/reporting-webhook.json" + }, + "governance_context": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "push_notification_config": { + "$ref": "/schemas/core/push-notification-config.json" + }, + "context": { + "$ref": "/schemas/core/context.json" + }, + "ext": { "$ref": "/schemas/core/ext.json" } + }, + "required": ["idempotency_key", "account", "media_buy_id", "revision"], + "anyOf": [ + { "required": ["paused"] }, + { "required": ["canceled"] }, + { "required": ["total_budget"] }, + { "required": ["budget_allocation"] }, + { "required": ["pacing"] }, + { "required": ["bidding"] }, + { "required": ["packages"] }, + { "required": ["reporting_webhook"] } + ], + "allOf": [ + { + "if": { "required": ["cancellation_reason"] }, + "then": { "required": ["canceled"] } + }, + { + "if": { "required": ["canceled"] }, + "then": { + "not": { + "anyOf": [ + { "required": ["paused"] }, + { "required": ["total_budget"] }, + { "required": ["budget_allocation"] }, + { "required": ["pacing"] }, + { "required": ["bidding"] }, + { "required": ["packages"] }, + { "required": ["reporting_webhook"] } + ] + } + } + } + ], + "additionalProperties": false, + "not": { + "anyOf": [ + { "required": ["creatives"] }, + { "required": ["creative_assignments"] }, + { "required": ["new_packages"] }, + { "required": ["start_time"] }, + { "required": ["end_time"] }, + { "required": ["invoice_recipient"] } + ] + } +} diff --git a/static/schemas/source/media-buy/control-media-buy-response.json b/static/schemas/source/media-buy/control-media-buy-response.json new file mode 100644 index 0000000000..c105782ad5 --- /dev/null +++ b/static/schemas/source/media-buy/control-media-buy-response.json @@ -0,0 +1,69 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/control-media-buy-response.json", + "title": "Control Media Buy Response", + "description": "Result of applying operational controls without embedding the package or creative object graphs.", + "type": "object", + "discriminator": { + "propertyName": "status" + }, + "oneOf": [ + { + "title": "Control Applied", + "type": "object", + "properties": { + "status": { "type": "string", "const": "completed" }, + "media_buy_id": { "type": "string", "minLength": 1, "x-entity": "media_buy" }, + "revision": { "type": "integer", "minimum": 1 }, + "media_buy_status": { "$ref": "/schemas/enums/media-buy-status.json" }, + "implementation_date": { "type": ["string", "null"], "format": "date-time" }, + "affected_package_ids": { + "type": "array", + "items": { "type": "string", "minLength": 1, "x-entity": "package" }, + "uniqueItems": true + }, + "available_actions": { + "type": "array", + "items": { "$ref": "/schemas/core/canonical-media-buy-action.json" }, + "uniqueItems": true + }, + "context": { "$ref": "/schemas/core/context.json" }, + "ext": { "$ref": "/schemas/core/ext.json" }, + "replayed": { "type": "boolean", "const": true } + }, + "required": ["status", "media_buy_id", "revision"], + "additionalProperties": false, + "not": { "required": ["errors"] } + }, + { + "title": "Control Error", + "type": "object", + "properties": { + "status": { "type": "string", "const": "failed" }, + "errors": { "type": "array", "items": { "$ref": "/schemas/core/error.json" }, "minItems": 1 }, + "context": { "$ref": "/schemas/core/context.json" }, + "ext": { "$ref": "/schemas/core/ext.json" }, + "replayed": { "type": "boolean", "const": true } + }, + "required": ["status", "errors"], + "additionalProperties": false, + "not": { "required": ["media_buy_id"] } + }, + { + "title": "Control Submitted", + "type": "object", + "properties": { + "status": { "type": "string", "const": "submitted" }, + "task_id": { "type": "string", "minLength": 1, "x-entity": "task" }, + "message": { "type": "string", "maxLength": 2000 }, + "errors": { "type": "array", "items": { "$ref": "/schemas/core/error.json" } }, + "context": { "$ref": "/schemas/core/context.json" }, + "ext": { "$ref": "/schemas/core/ext.json" }, + "replayed": { "type": "boolean", "const": true } + }, + "required": ["status", "task_id"], + "additionalProperties": false, + "not": { "required": ["media_buy_id"] } + } + ] +} diff --git a/static/schemas/source/media-buy/create-media-buy-request.json b/static/schemas/source/media-buy/create-media-buy-request.json index bd744fc535..c941b65d33 100644 --- a/static/schemas/source/media-buy/create-media-buy-request.json +++ b/static/schemas/source/media-buy/create-media-buy-request.json @@ -2,8 +2,11 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/create-media-buy-request.json", "title": "Create Media Buy Request", - "description": "Request parameters for creating a media buy. Supports explicit packages with fixed or seller-optimized budget allocation, or execution of a committed proposal with proposal_id and total_budget. Exactly one of packages or proposal_id must be provided.", + "description": "Deprecated AdCP 3.x compatibility request for creating a MediaBuy. New 3.2 callers use buy_products for direct published-offer purchases or accept_proposal for proposal execution. Existing package and proposal payloads remain valid throughout 3.x.", "type": "object", + "deprecated": true, + "x-deprecated-in": "3.2.0", + "x-superseded-by": ["buy_products", "accept_proposal"], "allOf": [ { "$ref": "/schemas/core/version-envelope.json" diff --git a/static/schemas/source/media-buy/decline-proposals-async-response-input-required.json b/static/schemas/source/media-buy/decline-proposals-async-response-input-required.json new file mode 100644 index 0000000000..baf1806ac9 --- /dev/null +++ b/static/schemas/source/media-buy/decline-proposals-async-response-input-required.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/decline-proposals-async-response-input-required.json", + "title": "Decline Proposals - Input Required", + "description": "Bounded input-required payload for decline_proposals.", + "allOf": [ + { "$ref": "/schemas/core/compact-task-input-required.json" } + ] +} diff --git a/static/schemas/source/media-buy/decline-proposals-async-response-submitted.json b/static/schemas/source/media-buy/decline-proposals-async-response-submitted.json new file mode 100644 index 0000000000..bdc14f0dfd --- /dev/null +++ b/static/schemas/source/media-buy/decline-proposals-async-response-submitted.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/decline-proposals-async-response-submitted.json", + "title": "Decline Proposals - Submitted", + "description": "Bounded submitted task envelope for decline_proposals.", + "allOf": [ + { "$ref": "/schemas/core/compact-task-submitted.json" } + ] +} diff --git a/static/schemas/source/media-buy/decline-proposals-async-response-working.json b/static/schemas/source/media-buy/decline-proposals-async-response-working.json new file mode 100644 index 0000000000..353570f50f --- /dev/null +++ b/static/schemas/source/media-buy/decline-proposals-async-response-working.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/decline-proposals-async-response-working.json", + "title": "Decline Proposals - Working", + "description": "Bounded working progress payload for decline_proposals.", + "allOf": [ + { "$ref": "/schemas/core/compact-task-working.json" } + ] +} diff --git a/static/schemas/source/media-buy/decline-proposals-response.json b/static/schemas/source/media-buy/decline-proposals-response.json index 3929c62ef4..2101b0de75 100644 --- a/static/schemas/source/media-buy/decline-proposals-response.json +++ b/static/schemas/source/media-buy/decline-proposals-response.json @@ -4,6 +4,20 @@ "title": "Decline Proposals Response", "description": "One ordered terminal result for each requested proposal decline.", "type": "object", + "anyOf": [ + { + "required": ["results"], + "not": { + "anyOf": [ + { "required": ["status"] }, + { "required": ["task_id"] } + ] + } + }, + { + "$ref": "/schemas/core/compact-task-submitted.json" + } + ], "properties": { "results": { "type": "array", @@ -41,8 +55,14 @@ ], "additionalProperties": false } - } + }, + "status": { "type": "string", "const": "submitted" }, + "task_id": { "type": "string", "minLength": 1, "x-entity": "task" }, + "message": { "type": "string", "maxLength": 2000 }, + "errors": { "type": "array", "items": { "$ref": "/schemas/core/error.json" } }, + "context": { "$ref": "/schemas/core/context.json" }, + "ext": { "$ref": "/schemas/core/ext.json" }, + "replayed": { "type": "boolean", "const": true } }, - "required": ["results"], - "additionalProperties": true + "additionalProperties": false } diff --git a/static/schemas/source/media-buy/get-media-buys-response.json b/static/schemas/source/media-buy/get-media-buys-response.json index eb7f7775d0..e9a219a092 100644 --- a/static/schemas/source/media-buy/get-media-buys-response.json +++ b/static/schemas/source/media-buy/get-media-buys-response.json @@ -24,6 +24,31 @@ "description": "Seller's unique identifier for the media buy", "x-entity": "media_buy" }, + "accepted_proposal_id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "x-entity": "proposal", + "description": "Current accepted commercial snapshot for compact-lifecycle refinement. Updated atomically when an amendment or negotiated cancellation is accepted." + }, + "accepted_proposal_terms_digest": { + "type": "string", + "pattern": "^sha256:[A-Za-z0-9_-]{43}$", + "description": "Digest of the current accepted proposal commercial_terms." + }, + "accepted_proposal": { + "allOf": [ + { "$ref": "/schemas/core/canonical-proposal.json" }, + { + "properties": { + "proposal_status": { "type": "string", "const": "accepted" }, + "media_buy_id": { "type": "string", "minLength": 1, "x-entity": "media_buy" } + }, + "required": ["proposal_status", "media_buy_id", "accepted_at", "commercial_terms", "terms_digest"] + } + ], + "description": "Current accepted compact proposal, including the complete digested commercial envelope. Required whenever accepted_proposal_id is present so restarted SDKs can recover control-versus-refinement routing without reconstructing historical offers." + }, "account": { "$ref": "/schemas/core/account.json", "description": "Account billed for this media buy" @@ -164,7 +189,10 @@ "type": "array", "description": "Structured per-buy resolution of the actions buyer can perform right now. Authoritative — divergence from product `allowed_actions[]` is expected (negotiated terms, account tier, buy-level overrides live on the deal, not the product). Each entry carries the resolved `mode` (singular, since the buy has a concrete state), optional `sla` commitment, and optional `terms_ref`. Predicate queries via #4425's `requires` grammar address fields by dotted path, e.g. `available_actions.extend_flight.sla.response_max`. Absent SLA means no commitment, not zero commitment — callers composing duration predicates MUST also compose with `present: true` to avoid silently matching sellers who never declared one.", "items": { - "$ref": "/schemas/core/media-buy-available-action.json" + "anyOf": [ + { "$ref": "/schemas/core/canonical-media-buy-action.json" }, + { "$ref": "/schemas/core/media-buy-available-action.json" } + ] }, "uniqueItems": true }, @@ -533,7 +561,34 @@ "$ref": "/schemas/core/ext.json" } }, + "dependencies": { + "accepted_proposal_id": ["accepted_proposal_terms_digest", "accepted_proposal"], + "accepted_proposal_terms_digest": ["accepted_proposal_id", "accepted_proposal"], + "accepted_proposal": ["accepted_proposal_id", "accepted_proposal_terms_digest"] + }, + "x-adcp-validation": { + "verifier_constraints": { + "accepted_proposal_binding": { + "proposal_id": "equals_accepted_proposal_id", + "terms_digest": "equals_accepted_proposal_terms_digest", + "media_buy_id": "equals_containing_media_buy_id", + "proposal_status": "accepted", + "on_violation": "reject_response" + } + } + }, "allOf": [ + { + "if": { "required": ["accepted_proposal"] }, + "then": { + "properties": { + "available_actions": { + "items": { "$ref": "/schemas/core/canonical-media-buy-action.json" } + } + }, + "required": ["available_actions"] + } + }, { "$comment": "When get_media_buys gains canonical media_buy_status during the 3.1 -> 3.2 status migration, extend this provisional-buy guard to reject media_buy_status: active alongside legacy status: active.", "if": { diff --git a/static/schemas/source/media-buy/list-products-request.json b/static/schemas/source/media-buy/list-products-request.json index d194504217..1c4a930709 100644 --- a/static/schemas/source/media-buy/list-products-request.json +++ b/static/schemas/source/media-buy/list-products-request.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/list-products-request.json", "title": "List Products Request", - "description": "Read seller offers with structured discovery criteria. This task is synchronous and side-effect free; proposal creation is handled by request_proposals.", + "description": "Read seller offers with structured discovery criteria. Each invocation completes synchronously, while durable wholesale product-feed webhooks registered through sync_accounts keep buyer mirrors current without polling. list_products is the authoritative bootstrap and repair read for those subscriptions; proposal creation is handled by request_proposals.", "type": "object", "x-operation-family": "list_products", "x-added-in": "3.2.0", @@ -25,13 +25,12 @@ "governance_context": { "type": "string", "minLength": 1, "maxLength": 4096 }, "push_notification_config": { "allOf": [{ "$ref": "/schemas/core/push-notification-config.json" }], - "x-adcp-schema-uri": "/schemas/core/push-notification-config.json" + "x-adcp-schema-uri": "/schemas/core/push-notification-config.json", + "description": "Uniform per-call envelope field accepted for SDK compatibility. This does not register a wholesale feed subscription; durable product.* and wholesale_feed.bulk_change subscribers are registered through sync_accounts notification_configs." }, - "account_id": { - "type": "string", - "minLength": 1, - "x-entity": "account", - "description": "Seller-known account whose pricing or availability overlay should be applied." + "account": { + "allOf": [{ "$ref": "/schemas/core/canonical-account-ref.json" }], + "description": "Account scope for pricing and availability. A natural-key account is the single brand source and MUST NOT be combined with top-level brand." }, "brand": { "$ref": "/schemas/core/brand-key.json" }, "criteria": { "$ref": "/schemas/media-buy/product-discovery-criteria.json" }, @@ -40,7 +39,7 @@ "max_results": { "type": "integer", "minimum": 1, "maximum": 100, "default": 25 }, "if_feed_version": { "type": "string", - "description": "Opaque feed version returned by a prior list_products response." + "description": "Opaque feed version returned by a prior list_products response or wholesale product-feed webhook for the same cache scope and canonicalized selection. Used for repair and conditional reconciliation, not routine polling when webhooks are active." }, "if_pricing_version": { "type": "string", @@ -56,7 +55,25 @@ "properties": { "criteria": { "required": ["catalog"] } }, "required": ["criteria"] }, - "then": { "required": ["brand"] } + "then": { + "anyOf": [ + { "required": ["brand"] }, + { + "properties": { + "account": { "required": ["brand", "operator"] } + }, + "required": ["account"] + } + ] + } + }, + { + "not": { + "properties": { + "account": { "required": ["brand", "operator"] } + }, + "required": ["account", "brand"] + } } ], "additionalProperties": false diff --git a/static/schemas/source/media-buy/list-products-response.json b/static/schemas/source/media-buy/list-products-response.json index 9fa9f5198b..192f118c28 100644 --- a/static/schemas/source/media-buy/list-products-response.json +++ b/static/schemas/source/media-buy/list-products-response.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/list-products-response.json", "title": "List Products Response", - "description": "Canonical product offers and continuation state. Products require format_options and never carry legacy named-format identifiers. This response never contains proposals or proposal-lifecycle fields.", + "description": "Canonical product offers and continuation state. Every product carries product_id and name; other compact detail fields follow the request's fields selection. Legacy named-format identifiers are never returned. This response never contains proposals or proposal-lifecycle fields.", "type": "object", "discriminator": { "propertyName": "outcome" @@ -22,17 +22,34 @@ "minLength": 1, "description": "Cursor for the next page. Omitted when this is the last page." }, - "feed_version": { "type": "string", "description": "Opaque version of the selected offer feed." }, + "feed_version": { "type": "string", "description": "Opaque version of the selected offer feed. For wholesale webhook consumers this is the same scope-keyed token carried as wholesale_feed_version on product.* and wholesale_feed.bulk_change notifications." }, "pricing_version": { "type": "string", "description": "Opaque version of the selected pricing layer." }, "cache_scope": { "type": "string", "enum": ["public", "account"] }, - "incomplete": { "$ref": "/schemas/media-buy/get-products-response.json#/properties/incomplete" } + "incomplete": { + "type": "array", + "description": "Usable partial response with explicitly missing product-list scopes.", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "scope": { "type": "string", "enum": ["products", "pricing", "forecast", "wholesale_feed"] }, + "description": { "type": "string", "minLength": 1 }, + "estimated_wait": { "$ref": "/schemas/core/duration.json" } + }, + "required": ["scope", "description"], + "additionalProperties": false + } + }, + "replayed": { "type": "boolean", "const": true }, + "context": { "$ref": "/schemas/core/context.json" }, + "ext": { "$ref": "/schemas/core/ext.json" } }, "oneOf": [ { "properties": { "outcome": { "type": "string", "const": "listed" } }, - "required": ["outcome", "products"] + "required": ["outcome", "products", "feed_version", "cache_scope"] }, { "properties": { @@ -50,5 +67,5 @@ { "required": ["unchanged"] } ] }, - "additionalProperties": true + "additionalProperties": false } diff --git a/static/schemas/source/media-buy/media-buy-commitment-response.json b/static/schemas/source/media-buy/media-buy-commitment-response.json new file mode 100644 index 0000000000..f7fbc8aa93 --- /dev/null +++ b/static/schemas/source/media-buy/media-buy-commitment-response.json @@ -0,0 +1,133 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/media-buy-commitment-response.json", + "title": "Media Buy Commitment Response", + "description": "Shared result for clean product purchase and proposal acceptance. A successful result returns the MediaBuy identity and the immutable accepted commercial snapshot without embedding creative or package graphs.", + "type": "object", + "discriminator": { + "propertyName": "status" + }, + "oneOf": [ + { + "title": "Committed Media Buy", + "type": "object", + "x-adcp-validation": { + "verifier_constraints": { + "accepted_proposal_binding": { + "media_buy_id": "equals_accepted_proposal.media_buy_id", + "proposal_status": "accepted", + "on_violation": "reject_response" + } + } + }, + "properties": { + "status": { "type": "string", "const": "completed" }, + "media_buy_id": { + "type": "string", + "minLength": 1, + "x-entity": "media_buy" + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "media_buy_status": { + "$ref": "/schemas/enums/media-buy-status.json" + }, + "confirmed_at": { + "type": ["string", "null"], + "format": "date-time" + }, + "accepted_proposal": { + "allOf": [ + { "$ref": "/schemas/core/canonical-proposal.json" }, + { + "properties": { + "proposal_status": { "type": "string", "const": "accepted" }, + "media_buy_id": { "type": "string", "minLength": 1, "x-entity": "media_buy" } + }, + "required": ["proposal_status", "media_buy_id", "accepted_at", "commercial_terms", "terms_digest"] + } + ] + }, + "purchase_bindings": { + "type": "array", + "description": "Execution identities assigned to the immutable purchases. purchase_index is the zero-based position in accepted_proposal.commercial_terms.purchases and disambiguates repeated product IDs.", + "x-adcp-validation": { + "verifier_constraints": { + "complete_purchase_bijection": { + "binding_count": "equals_accepted_proposal.commercial_terms.purchases.length", + "purchase_indexes": "unique_contiguous_zero_based_range", + "product_id": "equals_indexed_purchase.product_id", + "package_ids": "unique", + "on_violation": "reject_response" + } + } + }, + "items": { + "type": "object", + "properties": { + "purchase_index": { "type": "integer", "minimum": 0 }, + "product_id": { "type": "string", "minLength": 1, "x-entity": "product" }, + "package_id": { "type": "string", "minLength": 1, "x-entity": "package" } + }, + "required": ["purchase_index", "product_id", "package_id"], + "additionalProperties": false + }, + "minItems": 1, + "uniqueItems": true + }, + "available_actions": { + "type": "array", + "items": { "$ref": "/schemas/core/canonical-media-buy-action.json" }, + "uniqueItems": true + }, + "context": { + "$ref": "/schemas/core/context.json" + }, + "ext": { "$ref": "/schemas/core/ext.json" }, + "replayed": { + "type": "boolean", + "const": true + } + }, + "required": ["status", "media_buy_id", "revision", "accepted_proposal", "purchase_bindings", "available_actions"], + "additionalProperties": false, + "not": { "required": ["errors"] } + }, + { + "title": "Commitment Error", + "type": "object", + "properties": { + "status": { "type": "string", "const": "failed" }, + "errors": { + "type": "array", + "items": { "$ref": "/schemas/core/error.json" }, + "minItems": 1 + }, + "context": { "$ref": "/schemas/core/context.json" }, + "ext": { "$ref": "/schemas/core/ext.json" }, + "replayed": { "type": "boolean", "const": true } + }, + "required": ["status", "errors"], + "additionalProperties": false, + "not": { "required": ["media_buy_id"] } + }, + { + "title": "Commitment Submitted", + "type": "object", + "properties": { + "status": { "type": "string", "const": "submitted" }, + "task_id": { "type": "string", "minLength": 1, "x-entity": "task" }, + "message": { "type": "string", "maxLength": 2000 }, + "errors": { "type": "array", "items": { "$ref": "/schemas/core/error.json" } }, + "context": { "$ref": "/schemas/core/context.json" }, + "ext": { "$ref": "/schemas/core/ext.json" }, + "replayed": { "type": "boolean", "const": true } + }, + "required": ["status", "task_id"], + "additionalProperties": false, + "not": { "required": ["media_buy_id"] } + } + ] +} diff --git a/static/schemas/source/media-buy/package-control.json b/static/schemas/source/media-buy/package-control.json new file mode 100644 index 0000000000..724f03b311 --- /dev/null +++ b/static/schemas/source/media-buy/package-control.json @@ -0,0 +1,167 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/package-control.json", + "title": "Package Control", + "description": "Operational controls for an existing package that remain inside its accepted commercial envelope. targeting_overlay is a complete replacement; keyword add/remove arrays are incremental, and the same keyword MUST NOT appear in both directions. Creative mutation, flight changes, new products, pricing changes, and billing-term changes require their dedicated lifecycle or a refined proposal.", + "type": "object", + "properties": { + "package_id": { + "type": "string", + "minLength": 1, + "x-entity": "package" + }, + "budget": { + "type": ["number", "null"], + "minimum": 0 + }, + "min_spend_target": { + "type": ["number", "null"], + "minimum": 0 + }, + "impressions": { + "type": "number", + "minimum": 0 + }, + "pacing": { + "$ref": "/schemas/enums/pacing.json" + }, + "bidding": { + "anyOf": [ + { "$ref": "/schemas/core/bidding-policy.json" }, + { "type": "null" } + ] + }, + "paused": { + "type": "boolean" + }, + "canceled": { + "type": "boolean", + "const": true, + "description": "Exercise an already-accepted unilateral package cancellation right. If seller agreement is required, refine the accepted proposal with change_kind amendment; cancellation proposals terminate the whole MediaBuy." + }, + "cancellation_reason": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "targeting_overlay": { + "$ref": "/schemas/core/targeting.json", + "description": "Complete replacement for the package's prior targeting overlay, not a merge patch." + }, + "catalog_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "x-entity": "catalog" + }, + "minItems": 1, + "uniqueItems": true, + "description": "Replace the package's promoted catalogs with references previously managed through sync_catalogs." + }, + "keyword_targets_add": { + "type": "array", + "items": { "$ref": "/schemas/core/keyword-target.json" }, + "minItems": 1 + }, + "keyword_targets_remove": { + "type": "array", + "items": { "$ref": "/schemas/core/keyword-target.json" }, + "minItems": 1 + }, + "negative_keywords_add": { + "type": "array", + "items": { "$ref": "/schemas/core/keyword-target.json" }, + "minItems": 1 + }, + "negative_keywords_remove": { + "type": "array", + "items": { "$ref": "/schemas/core/keyword-target.json" }, + "minItems": 1 + }, + "optimization_goals": { + "type": "array", + "items": { "$ref": "/schemas/core/canonical-optimization-goal.json" }, + "minItems": 1 + } + }, + "required": ["package_id"], + "anyOf": [ + { "required": ["budget"] }, + { "required": ["min_spend_target"] }, + { "required": ["impressions"] }, + { "required": ["pacing"] }, + { "required": ["bidding"] }, + { "required": ["paused"] }, + { "required": ["canceled"] }, + { "required": ["targeting_overlay"] }, + { "required": ["catalog_ids"] }, + { "required": ["keyword_targets_add"] }, + { "required": ["keyword_targets_remove"] }, + { "required": ["negative_keywords_add"] }, + { "required": ["negative_keywords_remove"] }, + { "required": ["optimization_goals"] } + ], + "allOf": [ + { + "if": { "required": ["targeting_overlay"] }, + "then": { + "not": { + "anyOf": [ + { "required": ["keyword_targets_add"] }, + { "required": ["keyword_targets_remove"] }, + { "required": ["negative_keywords_add"] }, + { "required": ["negative_keywords_remove"] } + ] + } + } + }, + { + "if": { "required": ["cancellation_reason"] }, + "then": { "required": ["canceled"] } + }, + { + "if": { "required": ["canceled"] }, + "then": { + "not": { + "anyOf": [ + { "required": ["budget"] }, + { "required": ["min_spend_target"] }, + { "required": ["impressions"] }, + { "required": ["pacing"] }, + { "required": ["bidding"] }, + { "required": ["paused"] }, + { "required": ["targeting_overlay"] }, + { "required": ["catalog_ids"] }, + { "required": ["keyword_targets_add"] }, + { "required": ["keyword_targets_remove"] }, + { "required": ["negative_keywords_add"] }, + { "required": ["negative_keywords_remove"] }, + { "required": ["optimization_goals"] } + ] + } + } + } + ], + "x-adcp-validation": { + "verifier_constraints": { + "keyword_identity_sets": { + "positive_add_remove": "disjoint_by_normalized_keyword_and_match_type", + "negative_add_remove": "disjoint_by_normalized_keyword_and_match_type", + "on_violation": "reject_before_mutation" + } + } + }, + "additionalProperties": false, + "not": { + "anyOf": [ + { "required": ["creatives"] }, + { "required": ["creative_assignments"] }, + { "required": ["start_time"] }, + { "required": ["end_time"] }, + { "required": ["product_id"] }, + { "required": ["pricing_option_id"] }, + { "required": ["catalogs"] } + ] + } +} diff --git a/static/schemas/source/media-buy/product-fields.json b/static/schemas/source/media-buy/product-fields.json index 6d03f1b72a..c490873a1f 100644 --- a/static/schemas/source/media-buy/product-fields.json +++ b/static/schemas/source/media-buy/product-fields.json @@ -24,32 +24,20 @@ "exclusivity", "pricing_options", "forecast", - "outcome_measurement", - "delivery_measurement", "reporting_capabilities", - "creative_policy", + "measurement_terms", + "performance_standards", "catalog_types", - "metric_optimization", - "conversion_tracking", - "data_provider_signals", - "included_signals", "signal_targeting_allowed", - "signal_targeting_options", "signal_targeting_rules", "demographic_targeting", "audience_evidence", "audience_evidence_selections", "max_optimization_goals", "catalog_match", - "collections", - "collection_targeting_allowed", - "installments", "brief_relevance", "expires_at", - "product_card", - "product_card_detailed", - "enforced_policies", - "trusted_match" + "allowed_actions" ] } } diff --git a/static/schemas/source/media-buy/product-purchase.json b/static/schemas/source/media-buy/product-purchase.json new file mode 100644 index 0000000000..5674ab9714 --- /dev/null +++ b/static/schemas/source/media-buy/product-purchase.json @@ -0,0 +1,131 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/product-purchase.json", + "title": "Product Purchase", + "description": "One canonical product selection shared by buy_products inputs and compact proposal snapshots. Direct purchases accept published terms as-is; sellers resolve omitted inherited flight, measurement, and performance terms into the accepted commercial snapshot. Changes to those terms belong in the proposal lifecycle. Creative content and creative assignments are intentionally absent.", + "type": "object", + "x-adcp-validation": { + "verifier_constraints": { + "direct_purchase_terms": "On buy_products, supplied start_time, end_time, measurement_terms, and performance_standards MUST exactly match the selected published offer; omission inherits that offer.", + "accepted_snapshot_terms": "Inside canonical-proposal.commercial_terms, each purchase MUST carry resolved start_time and end_time and MUST preserve every applicable measurement term and performance standard. Omission of measurement_terms or performance_standards means the accepted offer declared none.", + "pricing_identity": { + "pricing_option_id": "equals_pricing.pricing_option_id_when_pricing_present", + "on_violation": "reject_before_commitment" + } + } + }, + "properties": { + "product_id": { + "type": "string", + "minLength": 1, + "x-entity": "product" + }, + "pricing_option_id": { + "type": "string", + "minLength": 1, + "x-entity": "product_pricing_option" + }, + "pricing": { + "$ref": "/schemas/core/canonical-pricing-option.json", + "description": "Resolved selected pricing terms. Optional on buy_products input, where pricing_option_id plus the versioned feed identifies the offer; required inside accepted commercial_terms. Its pricing_option_id MUST match the sibling field." + }, + "format_option_refs": { + "type": "array", + "items": { "$ref": "/schemas/core/format-option-ref.json" }, + "minItems": 1, + "description": "Canonical format options selected from the published product offer. Legacy named-format identifiers are not accepted." + }, + "catalog_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "x-entity": "catalog" + }, + "minItems": 1, + "uniqueItems": true, + "description": "Previously synchronized account catalog IDs promoted by this selection. Callers manage catalog bodies through sync_catalogs rather than inlining them here." + }, + "budget": { + "type": "number", + "minimum": 0, + "description": "Hard spend cap for this selection in the media-buy currency." + }, + "min_spend_target": { + "type": "number", + "minimum": 0, + "description": "Soft lifetime spend target for seller-optimized allocation." + }, + "impressions": { + "type": "number", + "minimum": 0 + }, + "start_time": { + "type": "string", + "format": "date-time", + "not": { "const": "asap" }, + "description": "Resolved package flight start. On direct-purchase input, omission inherits the MediaBuy start; accepted proposal snapshots carry the resolved timestamp." + }, + "end_time": { + "type": "string", + "format": "date-time", + "description": "Resolved package flight end. On direct-purchase input, omission inherits the MediaBuy end; accepted proposal snapshots carry the resolved timestamp." + }, + "pacing": { + "$ref": "/schemas/enums/pacing.json" + }, + "bidding": { + "$ref": "/schemas/core/bidding-policy.json" + }, + "targeting_overlay": { + "$ref": "/schemas/core/targeting.json", + "description": "Buyer-selected targeting, including compatible wholesale signal selections, applied within the product's published targeting contract." + }, + "optimization_goals": { + "type": "array", + "items": { + "$ref": "/schemas/core/canonical-optimization-goal.json" + }, + "minItems": 1 + }, + "audience_evidence_requirements": { + "$ref": "/schemas/core/product-audience-evidence-requirements.json", + "description": "Buyer evidence-admissibility policy carried into the accepted purchase snapshot." + }, + "audience_evidence_pins": { + "type": "array", + "items": { "$ref": "/schemas/core/audience-evidence-pin.json" }, + "minItems": 1, + "description": "Exact immutable audience-evidence snapshots selected for package construction." + }, + "agency_estimate_number": { + "type": "string", + "maxLength": 100, + "description": "Package-level agency estimate or authorization reference." + }, + "context": { + "$ref": "/schemas/core/context.json", + "description": "Opaque buyer package correlation preserved in the accepted snapshot and readback." + }, + "ext": { "$ref": "/schemas/core/ext.json" }, + "measurement_terms": { + "$ref": "/schemas/core/canonical-measurement-terms.json", + "description": "Published or negotiated billing-measurement and makegood terms for this purchase. Direct buyers may omit this to inherit the product default; accepted proposal snapshots preserve the resolved terms." + }, + "performance_standards": { + "type": "array", + "items": { "$ref": "/schemas/core/canonical-performance-standard.json" }, + "minItems": 1, + "description": "Published or negotiated metric thresholds and measurement vendors. Direct buyers may omit this to inherit the product defaults; accepted proposal snapshots preserve every applicable standard." + } + }, + "required": ["product_id", "pricing_option_id"], + "additionalProperties": false, + "not": { + "anyOf": [ + { "required": ["creatives"] }, + { "required": ["creative_assignments"] }, + { "required": ["catalogs"] } + ] + } +} diff --git a/static/schemas/source/media-buy/proposal-refinement.json b/static/schemas/source/media-buy/proposal-refinement.json index 2ce9287bba..0f634a9499 100644 --- a/static/schemas/source/media-buy/proposal-refinement.json +++ b/static/schemas/source/media-buy/proposal-refinement.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/proposal-refinement.json", "title": "Proposal Refinement", - "description": "Requested revision of one executable proposal snapshot. A successful refinement produces a new immutable committed snapshot and leaves the source proposal unchanged.", + "description": "Fork a draft, committed, or accepted immutable proposal. Refining an accepted proposal creates an amendment or cancellation proposal against its MediaBuy; the source remains accepted and unchanged.", "type": "object", "properties": { "proposal_id": { @@ -10,10 +10,16 @@ "minLength": 1, "x-entity": "proposal" }, + "change_kind": { + "type": "string", + "enum": ["amendment", "cancellation"], + "default": "amendment", + "description": "Desired successor proposal. cancellation is valid only when the source proposal is accepted and attached to a non-terminal MediaBuy." + }, "instructions": { "type": "string", "minLength": 1, - "description": "Natural-language changes that are not represented by structured criteria." + "description": "Requested commercial changes or cancellation reason. The seller returns a new committed proposal snapshot; this text never directly mutates the source proposal or MediaBuy." } }, "required": ["proposal_id", "instructions"], diff --git a/static/schemas/source/media-buy/refine-proposals-async-response-input-required.json b/static/schemas/source/media-buy/refine-proposals-async-response-input-required.json new file mode 100644 index 0000000000..91466e6c22 --- /dev/null +++ b/static/schemas/source/media-buy/refine-proposals-async-response-input-required.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/refine-proposals-async-response-input-required.json", + "title": "Refine Proposals - Input Required", + "description": "Bounded input-required payload for refine_proposals.", + "allOf": [ + { "$ref": "/schemas/core/compact-task-input-required.json" } + ] +} diff --git a/static/schemas/source/media-buy/refine-proposals-async-response-submitted.json b/static/schemas/source/media-buy/refine-proposals-async-response-submitted.json new file mode 100644 index 0000000000..3a255f24ac --- /dev/null +++ b/static/schemas/source/media-buy/refine-proposals-async-response-submitted.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/refine-proposals-async-response-submitted.json", + "title": "Refine Proposals - Submitted", + "description": "Bounded submitted task envelope for refine_proposals.", + "allOf": [ + { "$ref": "/schemas/core/compact-task-submitted.json" } + ] +} diff --git a/static/schemas/source/media-buy/refine-proposals-async-response-working.json b/static/schemas/source/media-buy/refine-proposals-async-response-working.json new file mode 100644 index 0000000000..7e25370da9 --- /dev/null +++ b/static/schemas/source/media-buy/refine-proposals-async-response-working.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/refine-proposals-async-response-working.json", + "title": "Refine Proposals - Working", + "description": "Bounded working progress payload for refine_proposals.", + "allOf": [ + { "$ref": "/schemas/core/compact-task-working.json" } + ] +} diff --git a/static/schemas/source/media-buy/refine-proposals-request.json b/static/schemas/source/media-buy/refine-proposals-request.json index ee54937f0c..ffe6778d0c 100644 --- a/static/schemas/source/media-buy/refine-proposals-request.json +++ b/static/schemas/source/media-buy/refine-proposals-request.json @@ -30,7 +30,7 @@ }, "refinements": { "type": "array", - "description": "Proposal revisions to create. proposal_id values MUST be unique within the request; results preserve request order.", + "description": "Proposal revisions to create. Sources may be draft, committed, or accepted. An accepted source is forked into a MediaBuy amendment or cancellation proposal and remains immutable. proposal_id values MUST be unique within the request; results preserve request order.", "items": { "$ref": "/schemas/media-buy/proposal-refinement.json" }, "minItems": 1, "uniqueItems": true diff --git a/static/schemas/source/media-buy/refine-proposals-response.json b/static/schemas/source/media-buy/refine-proposals-response.json index ad9216aa7a..da99c731d1 100644 --- a/static/schemas/source/media-buy/refine-proposals-response.json +++ b/static/schemas/source/media-buy/refine-proposals-response.json @@ -2,8 +2,22 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/refine-proposals-response.json", "title": "Refine Proposals Response", - "description": "One ordered immutable revision result per requested source proposal, plus the canonical products needed to evaluate the revised allocations. Products require format_options and never carry legacy named-format identifiers.", + "description": "One ordered immutable revision result per requested source proposal, plus compact canonical products needed to evaluate the revised purchases. Products always carry product_id and name and never carry legacy named-format identifiers.", "type": "object", + "anyOf": [ + { + "required": ["results", "products"], + "not": { + "anyOf": [ + { "required": ["status"] }, + { "required": ["task_id"] } + ] + } + }, + { + "$ref": "/schemas/core/compact-task-submitted.json" + } + ], "properties": { "results": { "type": "array", @@ -25,13 +39,13 @@ }, "proposal": { "allOf": [ - { "$ref": "/schemas/core/proposal.json" }, + { "$ref": "/schemas/core/canonical-proposal.json" }, { "properties": { "proposal_status": { "type": "string", "const": "committed" }, "expires_at": { "type": "string", "format": "date-time" } }, - "required": ["proposal_status", "expires_at"] + "required": ["proposal_status", "expires_at", "proposal_kind", "commercial_terms", "terms_digest"] } ] }, @@ -72,9 +86,15 @@ "products": { "type": "array", "items": { "$ref": "/schemas/core/canonical-product.json" } - } + }, + "status": { "type": "string", "const": "submitted" }, + "task_id": { "type": "string", "minLength": 1, "x-entity": "task" }, + "message": { "type": "string", "maxLength": 2000 }, + "errors": { "type": "array", "items": { "$ref": "/schemas/core/error.json" } }, + "context": { "$ref": "/schemas/core/context.json" }, + "ext": { "$ref": "/schemas/core/ext.json" }, + "replayed": { "type": "boolean", "const": true } }, - "required": ["results", "products"], "not": { "anyOf": [ { "required": ["proposals"] }, @@ -82,5 +102,5 @@ { "required": ["pagination"] } ] }, - "additionalProperties": true + "additionalProperties": false } diff --git a/static/schemas/source/media-buy/request-proposals-async-response-input-required.json b/static/schemas/source/media-buy/request-proposals-async-response-input-required.json new file mode 100644 index 0000000000..1cce4b3138 --- /dev/null +++ b/static/schemas/source/media-buy/request-proposals-async-response-input-required.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/request-proposals-async-response-input-required.json", + "title": "Request Proposals - Input Required", + "description": "Bounded input-required payload for request_proposals.", + "allOf": [ + { "$ref": "/schemas/core/compact-task-input-required.json" } + ] +} diff --git a/static/schemas/source/media-buy/request-proposals-async-response-submitted.json b/static/schemas/source/media-buy/request-proposals-async-response-submitted.json new file mode 100644 index 0000000000..051966ea77 --- /dev/null +++ b/static/schemas/source/media-buy/request-proposals-async-response-submitted.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/request-proposals-async-response-submitted.json", + "title": "Request Proposals - Submitted", + "description": "Bounded submitted task envelope for request_proposals.", + "allOf": [ + { "$ref": "/schemas/core/compact-task-submitted.json" } + ] +} diff --git a/static/schemas/source/media-buy/request-proposals-async-response-working.json b/static/schemas/source/media-buy/request-proposals-async-response-working.json new file mode 100644 index 0000000000..813090cd02 --- /dev/null +++ b/static/schemas/source/media-buy/request-proposals-async-response-working.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/media-buy/request-proposals-async-response-working.json", + "title": "Request Proposals - Working", + "description": "Bounded working progress payload for request_proposals.", + "allOf": [ + { "$ref": "/schemas/core/compact-task-working.json" } + ] +} diff --git a/static/schemas/source/media-buy/request-proposals-request.json b/static/schemas/source/media-buy/request-proposals-request.json index e2e264119e..9384d54dfb 100644 --- a/static/schemas/source/media-buy/request-proposals-request.json +++ b/static/schemas/source/media-buy/request-proposals-request.json @@ -28,11 +28,9 @@ "maxLength": 255, "pattern": "^[A-Za-z0-9_.:-]{16,255}$" }, - "account_id": { - "type": "string", - "minLength": 1, - "x-entity": "account", - "description": "Seller-known account for account-specific terms." + "account": { + "allOf": [{ "$ref": "/schemas/core/canonical-account-ref.json" }], + "description": "Account scope for proposal terms. A natural-key account is the single brand source and MUST NOT be combined with top-level brand." }, "brand": { "$ref": "/schemas/core/brand-key.json" }, "brief": { @@ -53,6 +51,25 @@ "description": "Optional planning-cycle context that the seller associates with every proposal created by this request." } }, - "required": ["idempotency_key", "brand", "brief"], + "required": ["idempotency_key", "brief"], + "anyOf": [ + { "required": ["brand"] }, + { + "properties": { + "account": { "required": ["brand", "operator"] } + }, + "required": ["account"] + } + ], + "allOf": [ + { + "not": { + "properties": { + "account": { "required": ["brand", "operator"] } + }, + "required": ["account", "brand"] + } + } + ], "additionalProperties": false } diff --git a/static/schemas/source/media-buy/request-proposals-response.json b/static/schemas/source/media-buy/request-proposals-response.json index deeb40b45b..1cb3c951be 100644 --- a/static/schemas/source/media-buy/request-proposals-response.json +++ b/static/schemas/source/media-buy/request-proposals-response.json @@ -2,11 +2,8 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/request-proposals-response.json", "title": "Request Proposals Response", - "description": "One or more executable media-plan proposals and the exact canonical products referenced by their allocations. Products require format_options and never carry legacy named-format identifiers. Returning only products does not satisfy request_proposals.", + "description": "One or more executable media-plan proposals and compact canonical products referenced by their purchases. Products always carry product_id and name and never carry legacy named-format identifiers. Returning only products does not satisfy request_proposals.", "type": "object", - "discriminator": { - "propertyName": "outcome" - }, "properties": { "outcome": { "type": "string", "enum": ["proposed", "rejected"] }, "reason": { "type": "string", "minLength": 1 }, @@ -20,13 +17,13 @@ "minItems": 1, "items": { "allOf": [ - { "$ref": "/schemas/core/proposal.json" }, + { "$ref": "/schemas/core/canonical-proposal.json" }, { "properties": { "proposal_status": { "type": "string", "const": "committed" }, "expires_at": { "type": "string", "format": "date-time" } }, - "required": ["proposal_status", "expires_at"] + "required": ["proposal_status", "expires_at", "proposal_kind", "commercial_terms", "terms_digest"] } ] } @@ -35,16 +32,25 @@ "type": "array", "minItems": 1, "items": { "$ref": "/schemas/core/canonical-product.json" } - } + }, + "status": { "type": "string", "const": "submitted" }, + "task_id": { "type": "string", "minLength": 1, "x-entity": "task" }, + "message": { "type": "string", "maxLength": 2000 }, + "errors": { "type": "array", "items": { "$ref": "/schemas/core/error.json" } }, + "context": { "$ref": "/schemas/core/context.json" }, + "ext": { "$ref": "/schemas/core/ext.json" }, + "replayed": { "type": "boolean", "const": true } }, - "oneOf": [ + "anyOf": [ { "properties": { "outcome": { "type": "string", "const": "proposed" } }, "required": ["outcome", "proposals", "products"], "not": { "anyOf": [ { "required": ["reason"] }, - { "required": ["suggestions"] } + { "required": ["suggestions"] }, + { "required": ["status"] }, + { "required": ["task_id"] } ] } }, @@ -54,9 +60,14 @@ "not": { "anyOf": [ { "required": ["proposals"] }, - { "required": ["products"] } + { "required": ["products"] }, + { "required": ["status"] }, + { "required": ["task_id"] } ] } + }, + { + "$ref": "/schemas/core/compact-task-submitted.json" } ], "not": { @@ -67,5 +78,5 @@ { "required": ["wholesale_feed_version"] } ] }, - "additionalProperties": true + "additionalProperties": false } diff --git a/static/schemas/source/media-buy/update-media-buy-request.json b/static/schemas/source/media-buy/update-media-buy-request.json index a485de3de3..1db5944a7d 100644 --- a/static/schemas/source/media-buy/update-media-buy-request.json +++ b/static/schemas/source/media-buy/update-media-buy-request.json @@ -2,8 +2,11 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/update-media-buy-request.json", "title": "Update Media Buy Request", - "description": "Request parameters for updating campaign and package settings", + "description": "Deprecated AdCP 3.x compatibility request for campaign, package, and creative mutation. New 3.2 callers use control_media_buy for operational controls, refine_proposals for commercial amendments or negotiated cancellation, and the dedicated creative lifecycle for creative changes.", "type": "object", + "deprecated": true, + "x-deprecated-in": "3.2.0", + "x-superseded-by": ["control_media_buy", "refine_proposals"], "allOf": [ { "$ref": "/schemas/core/version-envelope.json" diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index ea57189753..e9f24e8464 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -368,6 +368,9 @@ "type": "array", "description": "Task-scoped enforcement claims. The task field is a semantic uniqueness key: an agent MUST emit at most one entry per task and combine all supported modes in that entry. JSON Schema uniqueItems only rejects structurally identical objects, so producers and capability validators MUST enforce task-key uniqueness separately. Values correspond to request schemas annotated with x-governed-commitment. Online execution checks are currently defined only for media-buy tasks, whose prepared result has the PlannedDelivery contract; other roles can enforce signed intent authorization without inventing media-buy fields.", "items": { + "discriminator": { + "propertyName": "task" + }, "oneOf": [ { "properties": { @@ -397,6 +400,48 @@ "required": ["task", "modes"], "additionalProperties": false }, + { + "properties": { + "task": { "type": "string", "const": "buy_products" }, + "modes": { + "type": "array", + "items": { "type": "string", "enum": ["signed_context", "online_execution_check"] }, + "contains": { "const": "signed_context" }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": ["task", "modes"], + "additionalProperties": false + }, + { + "properties": { + "task": { "type": "string", "const": "accept_proposal" }, + "modes": { + "type": "array", + "items": { "type": "string", "enum": ["signed_context", "online_execution_check"] }, + "contains": { "const": "signed_context" }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": ["task", "modes"], + "additionalProperties": false + }, + { + "properties": { + "task": { "type": "string", "const": "control_media_buy" }, + "modes": { + "type": "array", + "items": { "type": "string", "enum": ["signed_context", "online_execution_check"] }, + "contains": { "const": "signed_context" }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": ["task", "modes"], + "additionalProperties": false + }, { "properties": { "task": { "type": "string", "const": "build_creative" }, @@ -634,12 +679,12 @@ "uniqueItems": true, "default": ["brief"] }, - "product_discovery_tools": { + "lifecycle_tools": { "type": "array", - "description": "Product-discovery operation names this seller supports. Added in AdCP 3.2 as compact, task-specific contracts that form the 4.0 lifecycle foundation. Sellers may advertise any supported subset while retaining get_products throughout 3.x. Absence means the caller uses the legacy facade. Each stateful split task has its own idempotency identity; callers MUST retry with the same tool name.", + "description": "Compact product and MediaBuy lifecycle operation names this seller supports. Added in AdCP 3.2 as task-specific contracts that form the 4.0 lifecycle foundation. Sellers may advertise any supported subset while retaining the deprecated get_products/create_media_buy/update_media_buy facades throughout 3.x. Each stateful split task has its own idempotency identity; callers MUST retry with the same tool name.", "items": { "type": "string", - "enum": ["get_products", "list_products", "request_proposals", "refine_proposals", "decline_proposals"] + "enum": ["list_products", "request_proposals", "refine_proposals", "decline_proposals", "buy_products", "accept_proposal", "control_media_buy"] }, "minItems": 1, "uniqueItems": true @@ -2127,7 +2172,7 @@ }, "wholesale_feed_webhooks": { "type": "object", - "description": "Per-agent wholesale product-feed and wholesale signals-feed webhook capabilities. Declared by sales agents (products) and signals agents (signals). When supported is true, consumers can register sync_accounts.accounts[].notification_configs[] entries for product.* / signal.* / wholesale_feed.bulk_change and receive the actual change payload in each webhook. This is distinct from buyer-provided feeds managed by sync_catalogs. Consumers use get_products / get_signals with if_wholesale_feed_version as the repair and reconciliation path after missed or distrusted webhooks. See specs/wholesale-feed-webhooks.md. Webhook emission MUST apply the same caller/account authorization and scope predicate as the corresponding wholesale read; agents unable to guarantee per-principal filtering MUST NOT declare supported: true. Capability consistency: agents listing product.* event types MUST declare and support get_products with media_buy.buying_modes including wholesale; agents listing signal.* event types MUST declare and support get_signals with signals.discovery_modes including wholesale; agents listing wholesale_feed.bulk_change MUST have at least one of those wholesale repair paths and MUST only emit bulk-change payloads for affected_entity_type values backed by a declared repair path.", + "description": "Per-agent wholesale product-feed and wholesale signals-feed webhook capabilities. Consumers register durable sync_accounts notification subscribers and receive actual product.*, signal.*, or wholesale_feed.bulk_change payloads without polling. Product mirrors bootstrap and repair through list_products(if_feed_version); signal mirrors use get_signals(if_wholesale_feed_version). Deprecated wholesale get_products remains the 3.x product compatibility path. Webhook emission MUST apply the same caller/account authorization and cache-scope predicate as the corresponding read.", "properties": { "supported": { "type": "boolean", @@ -2135,7 +2180,7 @@ }, "event_types": { "type": "array", - "description": "Wholesale feed webhook event types this agent can emit. Sales agents emit product.* events. Signals agents emit signal.* events. Agents that are both can emit both event families. Agents listing product.* event types MUST declare and support get_products with media_buy.buying_modes including wholesale. Agents listing signal.* event types MUST declare and support get_signals with signals.discovery_modes including wholesale. wholesale_feed.bulk_change tells consumers to repair by re-reading the affected wholesale feed via get_products and/or get_signals; agents listing it MUST have at least one of those wholesale repair paths and MUST only emit bulk-change payloads for affected_entity_type values backed by a declared repair path.", + "description": "Wholesale feed webhook event types this agent can emit. Sales agents emit product.* events and MUST expose list_products or the deprecated 3.x wholesale get_products compatibility path. Signals agents emit signal.* events and MUST support wholesale get_signals. wholesale_feed.bulk_change requires at least one corresponding repair path.", "items": { "type": "string", "enum": [ diff --git a/tests/composed-schema-validation.test.cjs b/tests/composed-schema-validation.test.cjs index 5d8e3272d5..137f08ab17 100644 --- a/tests/composed-schema-validation.test.cjs +++ b/tests/composed-schema-validation.test.cjs @@ -2825,6 +2825,8 @@ async function runTests() { }; const canonicalProductBase = structuredClone(productBase); delete canonicalProductBase.format_ids; + delete canonicalProductBase.delivery_measurement; + canonicalProductBase.pricing_options = [{ pricing_option_id: 'cpm', pricing_model: 'cpm', fixed_price: 10, currency: 'USD' }]; canonicalProductBase.format_options = [{ format_kind: 'image', params: { width: 300, height: 250 } @@ -3060,6 +3062,32 @@ async function runTests() { }, 'request_proposals requires a brief and accepts a replay key' ); + await testSchemaValidation( + '/schemas/media-buy/request-proposals-request.json', + { + idempotency_key: 'request-proposals-natural-account-0001', + account: { + brand: { domain: 'acmeoutdoor.example' }, + operator: 'buyer.example', + sandbox: true + }, + brief: 'Reach streaming audio listeners in Rome' + }, + 'request_proposals accepts a natural-key account as the single brand source' + ); + await testSchemaRejection( + '/schemas/media-buy/request-proposals-request.json', + { + idempotency_key: 'request-proposals-confused-brand-0001', + account: { + brand: { domain: 'tenant-a.example' }, + operator: 'buyer.example' + }, + brand: { domain: 'tenant-b.example' }, + brief: 'This must be rejected before account lookup' + }, + 'request_proposals rejects duplicate natural-account and top-level brand identity' + ); await testSchemaRejection( '/schemas/media-buy/request-proposals-request.json', { brief: 'Reach streaming audio listeners in Rome' }, @@ -3085,6 +3113,18 @@ async function runTests() { }, 'refine_proposals accepts plural proposal-scoped immutable refinements' ); + await testSchemaValidation( + '/schemas/media-buy/refine-proposals-request.json', + { + idempotency_key: 'refine-accepted-cancel-0001', + refinements: [{ + proposal_id: 'accepted-proposal-1', + change_kind: 'cancellation', + instructions: 'Cancel at the earliest date permitted by the accepted terms.' + }] + }, + 'refine_proposals forks an accepted proposal into a cancellation proposal' + ); await testSchemaRejection( '/schemas/media-buy/refine-proposals-request.json', { @@ -3093,6 +3133,169 @@ async function runTests() { }, 'refine_proposals rejects finalization' ); + const cleanPurchase = { + idempotency_key: 'buy-products-clean-0001', + account: { account_id: 'account-clean-1' }, + brand: { domain: 'buyer.example' }, + feed_version: 'feed-version-1', + pricing_version: 'pricing-version-1', + purchases: [{ + product_id: 'display-standard', + pricing_option_id: 'fixed-cpm', + budget: 50000 + }], + start_time: 'asap', + end_time: '2027-07-01T00:00:00Z' + }; + await testSchemaValidation( + '/schemas/media-buy/buy-products-request.json', + cleanPurchase, + 'buy_products creates directly from canonical product selections' + ); + await testSchemaRejection( + '/schemas/media-buy/buy-products-request.json', + { + ...cleanPurchase, + purchases: [{ + product_id: 'display-standard', + pricing_option_id: 'fixed-cpm', + creatives: [{ creative_id: 'legacy-inline' }] + }] + }, + 'buy_products rejects inline creatives' + ); + await testSchemaValidation( + '/schemas/media-buy/accept-proposal-request.json', + { + idempotency_key: 'accept-proposal-0001', + account: { account_id: 'account-clean-1' }, + proposal_id: 'proposal-committed-1', + proposal_terms_digest: `sha256:${'A'.repeat(43)}` + }, + 'accept_proposal needs only the committed proposal and execution identity' + ); + const cleanControl = { + idempotency_key: 'control-media-buy-0001', + account: { account_id: 'account-clean-1' }, + media_buy_id: 'media-buy-1', + revision: 4, + pacing: 'even', + packages: [{ package_id: 'package-1', paused: true }] + }; + await testSchemaValidation( + '/schemas/media-buy/control-media-buy-request.json', + cleanControl, + 'control_media_buy accepts operational controls inside accepted terms' + ); + await testSchemaValidation( + '/schemas/creative/sync-creatives-request.json', + { + idempotency_key: 'assignment-operations-0001', + account: { account_id: 'account-clean-1' }, + assignment_operations: [ + { operation: 'replace', package_id: 'package-1', replaces_creative_id: 'creative-old', creative_id: 'creative-new' }, + { operation: 'unassign', package_id: 'package-2', creative_id: 'creative-retired' } + ] + }, + 'sync_creatives supports assignment-only replace and unassign operations' + ); + await testSchemaRejection( + '/schemas/creative/sync-creatives-request.json', + { + idempotency_key: 'assignment-operations-lenient-0001', + account: { account_id: 'account-clean-1' }, + validation_mode: 'lenient', + assignment_operations: [ + { operation: 'assign', package_id: 'package-1', creative_id: 'creative-new' } + ] + }, + 'sync_creatives forbids partial lenient assignment operations' + ); + await testSchemaRejection( + '/schemas/media-buy/control-media-buy-request.json', + { ...cleanControl, end_time: '2027-08-01T00:00:00Z' }, + 'control_media_buy routes flight changes through proposal refinement' + ); + await testSchemaRejection( + '/schemas/media-buy/control-media-buy-request.json', + { ...cleanControl, creatives: [{ creative_id: 'legacy-inline' }] }, + 'control_media_buy rejects creative mutation' + ); + await testSchemaValidation( + '/schemas/media-buy/buy-products-response.json', + { + status: 'completed', + media_buy_id: 'media-buy-1', + revision: 1, + accepted_proposal: { + proposal_id: 'accepted-proposal-1', + name: 'Accepted direct purchase', + proposal_kind: 'new_media_buy', + proposal_status: 'accepted', + media_buy_id: 'media-buy-1', + accepted_at: '2027-06-01T12:00:00Z', + commercial_terms: { + source_feed_version: 'feed-version-1', + source_pricing_version: 'pricing-version-1', + brand: { domain: 'buyer.example' }, + purchases: [{ + product_id: 'display-standard', + pricing_option_id: 'fixed-cpm', + pricing: { pricing_option_id: 'fixed-cpm', pricing_model: 'cpm', currency: 'USD', fixed_price: 12 }, + budget: 50000, + start_time: '2027-06-01T12:00:00Z', + end_time: '2027-07-01T00:00:00Z' + }], + start_time: 'asap', + end_time: '2027-07-01T00:00:00Z' + }, + terms_digest: `sha256:${'A'.repeat(43)}` + }, + purchase_bindings: [{ purchase_index: 0, product_id: 'display-standard', package_id: 'package-1' }], + available_actions: [{ task: 'control_media_buy', action: 'update_catalog_assignments', mode: 'self_serve' }] + }, + 'buy_products returns an accepted immutable proposal snapshot' + ); + await testSchemaValidation( + '/schemas/media-buy/get-media-buys-response.json', + { + status: 'completed', + media_buys: [{ + media_buy_id: 'media-buy-1', + accepted_proposal_id: 'accepted-proposal-1', + accepted_proposal_terms_digest: `sha256:${'A'.repeat(43)}`, + accepted_proposal: { + proposal_id: 'accepted-proposal-1', + name: 'Recovered accepted terms', + proposal_kind: 'new_media_buy', + proposal_status: 'accepted', + media_buy_id: 'media-buy-1', + accepted_at: '2027-06-01T12:00:00Z', + commercial_terms: { + brand: { domain: 'buyer.example' }, + purchases: [{ + product_id: 'display-standard', + pricing_option_id: 'fixed-cpm', + pricing: { pricing_option_id: 'fixed-cpm', pricing_model: 'cpm', currency: 'USD', fixed_price: 12 }, + start_time: '2027-06-01T12:00:00Z', + end_time: '2027-07-01T00:00:00Z' + }], + start_time: 'asap', + end_time: '2027-07-01T00:00:00Z' + }, + terms_digest: `sha256:${'A'.repeat(43)}` + }, + status: 'active', + currency: 'USD', + total_budget: 50000, + confirmed_at: '2027-06-01T12:00:00Z', + revision: 2, + packages: [], + available_actions: [{ task: 'control_media_buy', action: 'update_catalog_assignments', mode: 'self_serve' }] + }] + }, + 'get_media_buys recovers accepted compact terms and routed actions after restart' + ); await testSchemaValidation( '/schemas/media-buy/request-proposals-request.json', { @@ -3236,21 +3439,34 @@ async function runTests() { { ...splitCapabilityBase, media_buy: { - product_discovery_tools: ['request_proposals', 'refine_proposals'] + lifecycle_tools: ['request_proposals', 'refine_proposals'] } }, 'compact proposal capability advertises supported split tools' ); await testSchemaValidation( '/schemas/media-buy/list-products-response.json', - { outcome: 'listed', products: [] }, + { outcome: 'listed', products: [], feed_version: 'feed-empty-1', cache_scope: 'public' }, 'list_products treats no matches as an empty successful product page' ); + await testSchemaValidation( + '/schemas/media-buy/refine-proposals-response.json', + { status: 'submitted', task_id: 'task-refinement-approval-1' }, + 'refine_proposals can enter an async approval workflow' + ); await testSchemaValidation( '/schemas/core/canonical-product.json', canonicalProductBase, 'split product tools accept canonical format options' ); + await testSchemaRejection( + '/schemas/core/canonical-product.json', + { + ...canonicalProductBase, + allowed_actions: [{ action: 'update_packages', modes: ['self_serve'] }] + }, + 'split product tools reject deprecated coarse MediaBuy actions' + ); await testSchemaRejection( '/schemas/core/canonical-product.json', productBase, @@ -3315,9 +3531,22 @@ async function runTests() { proposals: [{ proposal_id: 'proposal-1', name: 'Draft premium video plan', - allocations: [{ product_id: 'premium-video', allocation_percentage: 100 }], + proposal_kind: 'new_media_buy', proposal_status: 'committed', - expires_at: '2027-06-30T23:59:59Z' + expires_at: '2027-06-30T23:59:59Z', + commercial_terms: { + brand: { domain: 'buyer.example' }, + purchases: [{ + product_id: 'premium-video', + pricing_option_id: 'fixed-cpm', + pricing: { pricing_option_id: 'fixed-cpm', pricing_model: 'cpm', currency: 'USD', fixed_price: 28 }, + start_time: '2027-06-01T12:00:00Z', + end_time: '2027-07-01T00:00:00Z' + }], + start_time: 'asap', + end_time: '2027-07-01T00:00:00Z' + }, + terms_digest: `sha256:${'A'.repeat(43)}` }], products: [{ ...canonicalProductBase, product_id: 'premium-video' }] }, @@ -3449,6 +3678,81 @@ async function runTests() { }, 'Wholesale signal event accepts deprecated signal_id, optional legacy coverage_percentage, relaxed data_provider/pricing_options, and coverage_forecast' ); + await testSchemaValidation( + '/schemas/core/wholesale-feed-event.json', + { + event_id: '018f1f5d-7b6a-7cc2-8a1f-1234567890ab', + event_type: 'product.updated', + entity_type: 'product', + entity_id: 'canonical-product-1', + created_at: '2027-06-01T12:00:00Z', + payload: { + product_id: 'canonical-product-1', + canonical_product: { product_id: 'canonical-product-1', name: 'Canonical product' }, + changed_fields: ['name'], + applies_to: { scope: 'public' } + } + }, + 'Wholesale product event updates a list_products canonical mirror without legacy Product' + ); + await testSchemaValidation( + '/schemas/core/wholesale-feed-webhook.json', + { + idempotency_key: 'canonical-product-webhook-0001', + notification_id: '018f1f5d-7b6a-7cc2-8a1f-1234567890ab', + notification_type: 'product.updated', + fired_at: '2027-06-01T12:00:01Z', + subscriber_id: 'canonical-product-mirror', + account_id: 'account-1', + wholesale_feed_version: 'feed-version-2', + product_payload_view: 'canonical', + cache_scope: 'public', + event: { + event_id: '018f1f5d-7b6a-7cc2-8a1f-1234567890ab', + event_type: 'product.updated', + entity_type: 'product', + entity_id: 'canonical-product-1', + created_at: '2027-06-01T12:00:00Z', + payload: { + product_id: 'canonical-product-1', + canonical_product: { product_id: 'canonical-product-1', name: 'Canonical product' }, + applies_to: { scope: 'public' } + } + } + }, + 'Wholesale product webhook requires and echoes the canonical product view' + ); + await testSchemaRejection( + '/schemas/core/wholesale-feed-webhook.json', + { + idempotency_key: 'signal-webhook-view-0001', + notification_id: '018f4f28-6b5d-7f50-9d57-111111111111', + notification_type: 'signal.created', + fired_at: '2027-06-01T12:00:01Z', + subscriber_id: 'signal-mirror', + account_id: 'account-1', + wholesale_feed_version: 'signal-version-2', + product_payload_view: 'canonical', + cache_scope: 'public', + event: { + event_id: '018f4f28-6b5d-7f50-9d57-111111111111', + event_type: 'signal.created', + entity_type: 'signal', + entity_id: 'sig_auto_intenders', + created_at: '2027-06-01T12:00:00Z', + payload: { + signal_agent_segment_id: 'sig_auto_intenders', + applies_to: { scope: 'public' }, + signal: { + signal_id: legacySignalId, + ...signalListingCoreWithoutLegacyCoverage, + coverage_forecast: signalCoverageForecast + } + } + } + }, + 'Wholesale signal webhook rejects product view negotiation' + ); log('Registry change feed schemas:', 'info'); await testSchemaValidation( diff --git a/tests/docs-nav-validation.test.cjs b/tests/docs-nav-validation.test.cjs index 4fd84626a8..eb6b5d8898 100644 --- a/tests/docs-nav-validation.test.cjs +++ b/tests/docs-nav-validation.test.cjs @@ -385,6 +385,9 @@ test('temporary snapshot redirects cover every available live page', () => { 'docs/media-buy/task-reference/request_proposals', 'docs/media-buy/task-reference/refine_proposals', 'docs/media-buy/task-reference/decline_proposals', + 'docs/media-buy/task-reference/buy_products', + 'docs/media-buy/task-reference/accept_proposal', + 'docs/media-buy/task-reference/control_media_buy', 'docs/creative/channels/radio', 'docs/brand-protocol/tasks/search_brands', ]; diff --git a/tests/mcp-schema-projection.test.cjs b/tests/mcp-schema-projection.test.cjs index 53e7a6c80c..896a02455b 100644 --- a/tests/mcp-schema-projection.test.cjs +++ b/tests/mcp-schema-projection.test.cjs @@ -367,6 +367,65 @@ test('compact bundling reuses external schemas and keeps local refs resolvable', assert.ok(assertLocalRefsResolve(projectDraft07Node(compact)) > 0); }); +test('compact lifecycle routes every operational control and declares cross-item invariants', () => { + const routedActions = readJson(path.join(SOURCE_DIR, 'core', 'canonical-media-buy-action.json')); + const controlActions = new Set(routedActions.oneOf + .find(branch => branch.properties.task.const === 'control_media_buy') + .properties.action.enum); + const controlRequest = readJson(path.join(SOURCE_DIR, 'media-buy', 'control-media-buy-request.json')); + const packageControl = readJson(path.join(SOURCE_DIR, 'media-buy', 'package-control.json')); + const fieldRoutes = { + paused: ['pause', 'resume'], + canceled: ['cancel'], + total_budget: ['increase_budget', 'decrease_budget'], + budget_allocation: ['update_budget_allocation'], + pacing: ['update_pacing'], + bidding: ['update_bidding'], + reporting_webhook: ['update_reporting_webhook'], + budget: ['increase_budget', 'decrease_budget'], + min_spend_target: ['update_spend_target'], + impressions: ['update_impression_goal'], + targeting_overlay: ['update_targeting'], + catalog_ids: ['update_catalog_assignments'], + keyword_targets_add: ['update_keywords'], + keyword_targets_remove: ['update_keywords'], + negative_keywords_add: ['update_keywords'], + negative_keywords_remove: ['update_keywords'], + optimization_goals: ['update_optimization_goals'], + }; + for (const [field, actions] of Object.entries(fieldRoutes)) { + assert.ok(controlRequest.properties[field] || packageControl.properties[field], `${field} is not a control field`); + for (const action of actions) assert.ok(controlActions.has(action), `${field} lacks routed action ${action}`); + } + assert.equal( + controlRequest.properties.packages['x-adcp-validation'].verifier_constraints.unique_package_ids.key, + 'package_id' + ); + assert.equal( + packageControl['x-adcp-validation'].verifier_constraints.keyword_identity_sets.positive_add_remove, + 'disjoint_by_normalized_keyword_and_match_type' + ); + const commitment = readJson(path.join(SOURCE_DIR, 'media-buy', 'media-buy-commitment-response.json')); + const bindingRule = commitment.oneOf[0].properties.purchase_bindings['x-adcp-validation'] + .verifier_constraints.complete_purchase_bijection; + assert.equal(bindingRule.purchase_indexes, 'unique_contiguous_zero_based_range'); + assert.equal(bindingRule.product_id, 'equals_indexed_purchase.product_id'); + const productPurchase = readJson(path.join(SOURCE_DIR, 'media-buy', 'product-purchase.json')); + assert.equal( + productPurchase['x-adcp-validation'].verifier_constraints.pricing_identity.pricing_option_id, + 'equals_pricing.pricing_option_id_when_pricing_present' + ); + const commercialTerms = readJson(path.join(SOURCE_DIR, 'media-buy', 'commercial-terms.json')); + const pricingIntegrity = commercialTerms['x-adcp-validation'].verifier_constraints.pricing_integrity; + assert.equal(pricingIntegrity.purchase_currencies, 'all_purchase.pricing.currency_equal'); + assert.equal(pricingIntegrity.total_budget_currency, 'when_total_budget_present_equals_purchase_pricing_currency'); + const asyncUnion = readJson(path.join(SOURCE_DIR, 'core', 'async-response-data.json')); + const asyncRefs = new Set(asyncUnion.anyOf.map(branch => branch.$ref)); + for (const variant of ['submitted', 'working', 'input-required']) { + assert.ok(asyncRefs.has(`/schemas/core/compact-task-${variant}.json`)); + } +}); + test('generated MCP projection covers every tool within AdCP safety bounds', () => { assert.ok( fs.existsSync(PROJECTION_DIR), @@ -415,6 +474,34 @@ test('generated MCP projection covers every tool within AdCP safety bounds', () assert.equal(canonicalManifest.tools.request_proposals.legacy_fallback.mode, 'orchestrated'); assert.equal(canonicalManifest.tools.refine_proposals.legacy_fallback.mode, 'orchestrated'); assert.deepEqual(canonicalManifest.tools.decline_proposals.legacy_fallback, { mode: 'none' }); + assert.deepEqual(canonicalManifest.tools.buy_products.legacy_fallback, { + tool: 'create_media_buy', + mode: 'orchestrated', + }); + assert.equal(canonicalManifest.tools.accept_proposal.legacy_fallback.tool, 'create_media_buy'); + assert.equal(canonicalManifest.tools.control_media_buy.legacy_fallback.tool, 'update_media_buy'); + assert.deepEqual(canonicalManifest.tools.create_media_buy.superseded_by, [ + 'buy_products', + 'accept_proposal', + ]); + assert.deepEqual(canonicalManifest.tools.update_media_buy.superseded_by, [ + 'control_media_buy', + 'refine_proposals', + ]); + for (const toolName of [ + 'request_proposals', + 'refine_proposals', + 'decline_proposals', + 'buy_products', + 'accept_proposal', + 'control_media_buy', + ]) { + assert.deepEqual(canonicalManifest.tools[toolName].async_response_schemas, [ + `media-buy/${toolName.replaceAll('_', '-')}-async-response-input-required.json`, + `media-buy/${toolName.replaceAll('_', '-')}-async-response-submitted.json`, + `media-buy/${toolName.replaceAll('_', '-')}-async-response-working.json`, + ]); + } assert.deepEqual( Object.keys(projectionManifest.tools).sort(), Object.keys(canonicalManifest.tools).sort() @@ -432,6 +519,9 @@ test('generated MCP projection covers every tool within AdCP safety bounds', () 'request_proposals', 'refine_proposals', 'decline_proposals', + 'buy_products', + 'accept_proposal', + 'control_media_buy', ]) { const tool = projectionManifest.tools[toolName]; const input = JSON.stringify(readJson(path.join(PROJECTION_DIR, tool.inputSchema))); @@ -442,12 +532,23 @@ test('generated MCP projection covers every tool within AdCP safety bounds', () `${toolName} input must not expose legacy named-format creatives`); assert.doesNotMatch(output, /(?:AssetVariant|asset-variant\.json)/, `${toolName} output must not depend on creative asset variants`); - if (toolName !== 'decline_proposals') { + if (['list_products', 'request_proposals', 'refine_proposals'].includes(toolName)) { assert.match(output, /Canonical Product/, `${toolName} output must use the canonical-only Product view`); + assert.doesNotMatch(output, /format_ids|format-id\.json|v1_format_ref|update_packages|update_media_buy/, + `${toolName} output must not expose the legacy Product graph`); } } + const productionManifest = readJson(path.join(PRODUCTION_PROFILE_DIR, 'manifest.json')); + for (const compatibilityTool of ['get_products', 'create_media_buy', 'update_media_buy']) { + assert.equal( + productionManifest.tools[compatibilityTool], + undefined, + `${compatibilityTool} must be absent from the clean 3.2 production profile` + ); + } + const projectedListProductsOutput = readJson(path.join( PROJECTION_DIR, projectionManifest.tools.list_products.outputSchema @@ -462,9 +563,8 @@ test('generated MCP projection covers every tool within AdCP safety bounds', () pricing_options: [{ pricing_option_id: 'cpm', pricing_model: 'cpm', - rate: 10, + fixed_price: 10, currency: 'USD', - is_fixed: true, }], reporting_capabilities: { available_reporting_frequencies: ['daily'], @@ -477,6 +577,8 @@ test('generated MCP projection covers every tool within AdCP safety bounds', () }; assert.equal(validateProjectedListProducts({ outcome: 'listed', + feed_version: 'feed-1', + cache_scope: 'public', products: [{ ...projectedProductBase, format_options: [{ format_kind: 'image', params: { width: 300, height: 250 } }], @@ -484,6 +586,8 @@ test('generated MCP projection covers every tool within AdCP safety bounds', () }), true, JSON.stringify(validateProjectedListProducts.errors)); assert.equal(validateProjectedListProducts({ outcome: 'listed', + feed_version: 'feed-1', + cache_scope: 'public', products: [{ ...projectedProductBase, format_ids: [{ agent_url: 'https://legacy-creative.example', id: 'display_300x250' }], From 203ef2a2ec1f0c5d833b08ebfeb3328282b4be17 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 12 Aug 2026 10:41:55 +0200 Subject: [PATCH 37/49] fix(docs): pin compact lifecycle schema links --- docs/media-buy/task-reference/accept_proposal.mdx | 3 +-- docs/media-buy/task-reference/buy_products.mdx | 3 +-- docs/media-buy/task-reference/control_media_buy.mdx | 3 +-- docs/media-buy/task-reference/decline_proposals.mdx | 3 +-- docs/media-buy/task-reference/list_products.mdx | 3 +-- docs/media-buy/task-reference/refine_proposals.mdx | 3 +-- docs/media-buy/task-reference/request_proposals.mdx | 3 +-- 7 files changed, 7 insertions(+), 14 deletions(-) diff --git a/docs/media-buy/task-reference/accept_proposal.mdx b/docs/media-buy/task-reference/accept_proposal.mdx index c77c689a57..bccdc2a8eb 100644 --- a/docs/media-buy/task-reference/accept_proposal.mdx +++ b/docs/media-buy/task-reference/accept_proposal.mdx @@ -15,8 +15,7 @@ Depending on `proposal_kind`, acceptance: The proposal already carries the commercial terms, so the request does not repeat packages, dates, targeting, or creatives. -{/* Using latest because accept_proposal is not yet released. Update after the 3.2 release. */} -**Request schema:** [`/schemas/latest/media-buy/accept-proposal-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/accept-proposal-request.json) +**Request schema:** [`/schemas/v3/media-buy/accept-proposal-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/accept-proposal-request.json) ```json { diff --git a/docs/media-buy/task-reference/buy_products.mdx b/docs/media-buy/task-reference/buy_products.mdx index 5d31a3ec51..da32b93aa9 100644 --- a/docs/media-buy/task-reference/buy_products.mdx +++ b/docs/media-buy/task-reference/buy_products.mdx @@ -11,8 +11,7 @@ Creative objects and assignments are deliberately absent. Use the creative lifec Per-purchase flight dates, `measurement_terms`, and `performance_standards` may be omitted to inherit the published offer. The accepted snapshot resolves and preserves those terms. If this purchase ends a tracked planning cycle, `opportunity` closes it with `accepted_with_seller`. -{/* Using latest because buy_products is not yet released. Update after the 3.2 release. */} -**Request schema:** [`/schemas/latest/media-buy/buy-products-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/buy-products-request.json) +**Request schema:** [`/schemas/v3/media-buy/buy-products-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/buy-products-request.json) ```json { diff --git a/docs/media-buy/task-reference/control_media_buy.mdx b/docs/media-buy/task-reference/control_media_buy.mdx index 5192de4f00..d1141dea9c 100644 --- a/docs/media-buy/task-reference/control_media_buy.mdx +++ b/docs/media-buy/task-reference/control_media_buy.mdx @@ -17,8 +17,7 @@ Supported controls include: It does not accept creatives, creative assignments, new products/packages, flight-date changes, pricing changes, or billing-term changes. Those concerns use their dedicated lifecycle or a proposal refinement. -{/* Using latest because control_media_buy is not yet released. Update after the 3.2 release. */} -**Request schema:** [`/schemas/latest/media-buy/control-media-buy-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/control-media-buy-request.json) +**Request schema:** [`/schemas/v3/media-buy/control-media-buy-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/control-media-buy-request.json) ```json { diff --git a/docs/media-buy/task-reference/decline_proposals.mdx b/docs/media-buy/task-reference/decline_proposals.mdx index 1a49074707..dad33e452f 100644 --- a/docs/media-buy/task-reference/decline_proposals.mdx +++ b/docs/media-buy/task-reference/decline_proposals.mdx @@ -7,8 +7,7 @@ testable: false `decline_proposals` records that the buyer evaluated one or more proposals and will not pursue them. The operation is terminal: a seller must reject later [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) or new logical [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) attempts for a declined proposal with [`INVALID_STATE`](/docs/building/verification/compliance-catalog#error-code-invalid-state). Exact retries of an earlier successful call still follow the shared idempotency replay contract. A proposal that was already executed cannot subsequently be declined and returns `unable`. -{/* Using latest because decline_proposals is not yet released. Update after the 3.2 release. */} -**Request schema:** [`/schemas/latest/media-buy/decline-proposals-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/decline-proposals-request.json) +**Request schema:** [`/schemas/v3/media-buy/decline-proposals-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/decline-proposals-request.json) ```json { diff --git a/docs/media-buy/task-reference/list_products.mdx b/docs/media-buy/task-reference/list_products.mdx index 62ec976f88..216811b3f9 100644 --- a/docs/media-buy/task-reference/list_products.mdx +++ b/docs/media-buy/task-reference/list_products.mdx @@ -7,8 +7,7 @@ testable: false `list_products` is the side-effect-free entry point to the compact product lifecycle. Each invocation completes synchronously, while account-level wholesale feed webhooks keep long-lived buyer mirrors current without polling. It returns products only—never proposals. Buyers can purchase listed products through [`buy_products`](/docs/media-buy/task-reference/buy_products) or pass returned `product_id` values to [`request_proposals`](/docs/media-buy/task-reference/request_proposals) for seller planning. -{/* Using latest because list_products is not yet released. Update after the 3.2 release. */} -**Request schema:** [`/schemas/latest/media-buy/list-products-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/list-products-request.json) +**Request schema:** [`/schemas/v3/media-buy/list-products-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/list-products-request.json) ```json { diff --git a/docs/media-buy/task-reference/refine_proposals.mdx b/docs/media-buy/task-reference/refine_proposals.mdx index 43304cee9e..f1ab7b4fdd 100644 --- a/docs/media-buy/task-reference/refine_proposals.mdx +++ b/docs/media-buy/task-reference/refine_proposals.mdx @@ -7,8 +7,7 @@ testable: false `refine_proposals` creates executable committed revisions directly. Each entry targets one draft, committed, or accepted proposal. Proposal IDs must be unique within the request, and results preserve request order. Successful refinement returns a new `proposal_id`; that ID is the complete version identity for the immutable commercial snapshot, and the source remains unchanged. -{/* Using latest because accepted-proposal refinement is not yet released. Update after the 3.2 release. */} -**Request schema:** [`/schemas/latest/media-buy/refine-proposals-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/refine-proposals-request.json) +**Request schema:** [`/schemas/v3/media-buy/refine-proposals-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/refine-proposals-request.json) ```json { diff --git a/docs/media-buy/task-reference/request_proposals.mdx b/docs/media-buy/task-reference/request_proposals.mdx index 3a9fc617cd..c1062cdd75 100644 --- a/docs/media-buy/task-reference/request_proposals.mdx +++ b/docs/media-buy/task-reference/request_proposals.mdx @@ -9,8 +9,7 @@ testable: false `brand` contains only the stable BrandKey (`domain` plus optional `brand_id`); the seller resolves the canonical brand manifest rather than receiving a full brand file in every call. `account` is optional and adds seller-specific commercial terms. A natural-key account (`brand` plus `operator`) can supply the request's sole brand identity instead of repeating top-level `brand`. -{/* Using latest because request_proposals is not yet released. Update after the 3.2 release. */} -**Request schema:** [`/schemas/latest/media-buy/request-proposals-request.json`](https://adcontextprotocol.org/schemas/latest/media-buy/request-proposals-request.json) +**Request schema:** [`/schemas/v3/media-buy/request-proposals-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/request-proposals-request.json) ```json { From 44cb3f598565a0ce9b3237099c268a6d0ba23d0f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 12 Aug 2026 10:48:49 +0200 Subject: [PATCH 38/49] fix(media-buy): align compact runtime and completion fixtures --- server/src/training-agent/task-handlers.ts | 17 ++++++++++++++++- server/tests/unit/training-agent.test.ts | 1 + .../media-buy/scenarios/get_products_async.yaml | 1 + .../signals/scenarios/get_signals_async.yaml | 8 +++++--- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 8329df7990..62302dca35 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -4538,6 +4538,9 @@ export function validateProductDiscoveryAliasInput( toolName: string, args: Record, ): { message: string; field?: string } | undefined { + if (args.account !== undefined && !isRecord(args.account)) { + return { message: 'account must be an object', field: 'account' }; + } const account = isRecord(args.account) ? args.account : undefined; const hasNaturalAccountBrand = isRecord(account?.brand) && typeof account.operator === 'string' @@ -4624,7 +4627,19 @@ export function validateProductDiscoveryAliasInput( if (!(typeof entry.instructions === 'string' && entry.instructions.length > 0)) { return { message: 'each refinement requires instructions', field: `refinements[${index}].instructions` }; } - const unknown = Object.keys(entry).find(field => !['proposal_id', 'instructions'].includes(field)); + if ( + entry.change_kind !== undefined + && entry.change_kind !== 'amendment' + && entry.change_kind !== 'cancellation' + ) { + return { + message: 'change_kind must be amendment or cancellation', + field: `refinements[${index}].change_kind`, + }; + } + const unknown = Object.keys(entry).find( + field => !['proposal_id', 'change_kind', 'instructions'].includes(field), + ); if (unknown) { return { message: `${unknown} is not supported on proposal refinements`, field: `refinements[${index}].${unknown}` }; } diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index c321d6334e..696404a9fd 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -12818,6 +12818,7 @@ describe('proposal lifecycle', () => { const { result: refined, isError: refineError } = await simulateCallTool(server, 'refine_proposals', { refinements: [{ proposal_id: source.proposal_id, + change_kind: 'amendment', instructions: 'Prefer the social inventory while preserving the total budget.', }, { proposal_id: 'proposal-not-visible-to-caller', diff --git a/static/compliance/source/protocols/media-buy/scenarios/get_products_async.yaml b/static/compliance/source/protocols/media-buy/scenarios/get_products_async.yaml index ccc55076fe..b9bf506907 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/get_products_async.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/get_products_async.yaml @@ -370,6 +370,7 @@ phases: params: task_id: "$context.products_task_id" result: + status: "completed" products: - product_id: "async_curated_trail_display" name: "Async curated trail display" diff --git a/static/compliance/source/protocols/signals/scenarios/get_signals_async.yaml b/static/compliance/source/protocols/signals/scenarios/get_signals_async.yaml index 41a8c666f9..2d04844a33 100644 --- a/static/compliance/source/protocols/signals/scenarios/get_signals_async.yaml +++ b/static/compliance/source/protocols/signals/scenarios/get_signals_async.yaml @@ -288,10 +288,12 @@ phases: params: task_id: "$context.signals_task_id" result: + status: "completed" signals: - - signal_id: - source: "agent_native" - id: "likely_ev_buyers" + - signal_ref: + scope: "signal_source" + signal_source_url: "https://signals.example/.well-known/adcp/signals" + signal_id: "likely_ev_buyers" signal_agent_segment_id: "seg_async_likely_ev_buyers" name: "Likely EV buyers" description: "Modeled audience of consumers likely to consider an electric vehicle in the next 90 days." From 041bcf0649330d9f4c885f3e6ec236dcee71967e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 12 Aug 2026 12:35:40 +0200 Subject: [PATCH 39/49] feat(schemas): add active MCP role catalogs --- .changeset/secure-get-products-idempotency.md | 2 +- docs/building/by-layer/L0/schemas.mdx | 65 +++++++- scripts/build-schemas.cjs | 148 +++++++++++++++++- scripts/mcp-schema-projection.cjs | 98 ++++++++---- tests/mcp-schema-projection.test.cjs | 110 +++++++++++++ 5 files changed, 384 insertions(+), 39 deletions(-) diff --git a/.changeset/secure-get-products-idempotency.md b/.changeset/secure-get-products-idempotency.md index 5ecbd635dd..49e31ad757 100644 --- a/.changeset/secure-get-products-idempotency.md +++ b/.changeset/secure-get-products-idempotency.md @@ -2,6 +2,6 @@ "adcontextprotocol": minor --- -Add the compact AdCP 3.2 product and MediaBuy lifecycle: `list_products`, `request_proposals`, `refine_proposals`, `decline_proposals`, `buy_products`, `accept_proposal`, and `control_media_buy`. The task-specific contracts separate offer discovery, executable proposal creation, immutable refinement, terminal decline, direct purchase, proposal acceptance, and operational delivery control while retaining `get_products`, `create_media_buy`, and `update_media_buy` as compatibility facades throughout 3.x. New purchase and control inputs never accept inline creatives; commercial amendments and negotiated cancellations fork an accepted proposal, while operational controls remain revision-checked. Compact purchase snapshots preserve resolved package flight, billing-measurement, performance, and reporting terms. A shared opportunity reference connects planning-cycle context through purchase without duplicating proposal version identity. Canonical inputs use stable brand keys, catalog references, and compact account and optimization types so brand assets, legacy named formats, creative provenance, and compliance payloads do not transitively enter the clean tools. Publish machine-readable SDK fallback grades and task-result schema resolution, plus an MCP production profile that filters out compliance-only and deprecated tools and removes presentation annotations without changing validation semantics. +Add the compact AdCP 3.2 product and MediaBuy lifecycle: `list_products`, `request_proposals`, `refine_proposals`, `decline_proposals`, `buy_products`, `accept_proposal`, and `control_media_buy`. The task-specific contracts separate offer discovery, executable proposal creation, immutable refinement, terminal decline, direct purchase, proposal acceptance, and operational delivery control while retaining `get_products`, `create_media_buy`, and `update_media_buy` as compatibility facades throughout 3.x. New purchase and control inputs never accept inline creatives; commercial amendments and negotiated cancellations fork an accepted proposal, while operational controls remain revision-checked. Compact purchase snapshots preserve resolved package flight, billing-measurement, performance, and reporting terms. A shared opportunity reference connects planning-cycle context through purchase without duplicating proposal version identity. Canonical inputs use stable brand keys, catalog references, and compact account and optimization types so brand assets, legacy named formats, creative provenance, and compliance payloads do not transitively enter the clean tools. Publish machine-readable SDK fallback grades and task-result schema resolution, plus MCP production, media-buy, and creative catalogs that remove presentation annotations without changing validation semantics. The role catalogs select active 3.2 seller-hosted operations and publish client-side input-only prompt views while retaining output and terminal task-result schemas in their parent validation catalogs. Document the normative `PROPOSAL_UNAVAILABLE` rejection path when a seller did not reserve inventory, add `countries` and `property_list` product-attribute filters, and publish compact task-specific async envelopes for consultative proposal planning and re-underwriting. diff --git a/docs/building/by-layer/L0/schemas.mdx b/docs/building/by-layer/L0/schemas.mdx index aaa749f01a..8f0eebb6a0 100644 --- a/docs/building/by-layer/L0/schemas.mdx +++ b/docs/building/by-layer/L0/schemas.mdx @@ -236,13 +236,14 @@ https://adcontextprotocol.org/schemas/{version}/mcp/2026-07-28/profiles/producti For AdCP 3.2, this is the clean active structural catalog: it excludes the compliance-only controller and tools deprecated by 3.2, including the `get_products` and `list_creative_formats` compatibility facades. Its schemas -remove only presentation annotations (`description`, `title`, `examples`, and -`$comment`), so validation semantics are identical to the full MCP projection. +remove only presentation annotations (`description`, `enumDescriptions`, +`title`, `examples`, and `$comment`), so validation semantics are identical to +the full MCP projection. Each manifest tool retains its `protocol` classification for deterministic selection. The profile is not a recommendation to load all active AdCP tools into one -agent context. It currently spans 65 tools across the protocol families. A +agent context. It currently spans 66 tools across the protocol families. A production host MUST expose only the protocols and tools it implements and SHOULD further select the smallest capability-appropriate subset for each agent session. Use the full projection for documentation, compatibility, and @@ -250,6 +251,59 @@ conformance; use this profile as the filtered catalog and structural validation source from which a host builds that subset. Removing descriptions makes the artifacts smaller, but does not by itself solve `tools/list` context cost. +#### Active role catalogs + +Hosts and clients can use one of two role-filtered catalogs instead of selecting +active tools from the entire production profile: + +``` +https://adcontextprotocol.org/schemas/{version}/mcp/2026-07-28/profiles/media-buy/manifest.json +https://adcontextprotocol.org/schemas/{version}/mcp/2026-07-28/profiles/creative/manifest.json +``` + +The `media-buy` catalog covers active 3.2 operations for a seller-hosted +product-to-delivery role. It includes product discovery, proposals, purchase, +control, reporting, audiences, catalogs, event sources, accounts, governance, +and separately synchronized creatives. These operations are one production +lifecycle rather than separate "sales" and "sales lifecycle" surfaces. +Creative construction is deliberately excluded. + +The `creative` catalog covers creative construction, transformation, preview, +validation, catalog inputs, library synchronization, delivery, accounts, +governance, usage, and task management. Shared account, catalog, and +creative-trafficking tools intentionally appear in both catalogs; they describe +role-oriented active surfaces, not mutually exclusive protocol ownership. + +These are active-3.2 catalogs, not complete 3.x server registrations. A server +that supports callers using the deprecated 3.x compatibility facades must also +advertise the applicable `get_products`, `create_media_buy`, and +`update_media_buy` definitions from the full projection. Cross-agent buyer +orchestration can additionally require signals, brand, external governance, +property, or content-standard services that are intentionally outside the +seller-hosted role catalog. +Clients that combine compatibility facades with an active role catalog use the +full projection's `task_result_resolution`, because the role-scoped resolver +intentionally covers only task types present in that active catalog. + +Each role also publishes an input-only client prompt view: + +``` +https://adcontextprotocol.org/schemas/{version}/mcp/2026-07-28/profiles/media-buy/model-context/manifest.json +https://adcontextprotocol.org/schemas/{version}/mcp/2026-07-28/profiles/creative/model-context/manifest.json +``` + +Model-context manifests contain only structural `inputSchema` entries. They are +client-side prompt projections, not standalone MCP `tools/list` registrations: +servers SHOULD continue advertising `outputSchema`, and clients SHOULD validate +structured results with the parent role catalog. A controlled client can omit +the output schema only when assembling its model prompt while retaining the +parent response schemas and task-result resolution metadata out of band. + +Structural schemas deliberately omit descriptions. To support tool selection, +clients combine the model-context inputs with each live MCP tool's concise +`name` and `description`; the downloadable model-context manifest is not a +description catalog by itself. + AdCP 4.0 will make JSON Schema 2020-12 the canonical source dialect. That major-version migration is where the protocol may selectively use `unevaluatedProperties`, `dependentRequired`, `dependentSchemas`, and other @@ -289,7 +343,10 @@ dist/schemas/{VERSION}/ │ ├── protocol/ # Protocol tasks │ └── core/ # Core shared schemas and legacy task lifecycle schemas ├── mcp/2026-07-28/ # Self-contained JSON Schema 2020-12 tool projections -│ └── profiles/production/ # Active, non-compliance structural surface +│ └── profiles/ +│ ├── production/ # Active, non-compliance structural catalog +│ ├── media-buy/ # Active seller media-buy catalog + prompt view +│ └── creative/ # Active creative catalog + prompt view ├── core/ # Modular schemas with $ref ├── trusted-match/ # Serve-time Context Match and Identity Match schemas ├── media-buy/ diff --git a/scripts/build-schemas.cjs b/scripts/build-schemas.cjs index 936966d360..5c348b3375 100644 --- a/scripts/build-schemas.cjs +++ b/scripts/build-schemas.cjs @@ -44,6 +44,64 @@ const PACKAGE_JSON = path.join(__dirname, '../package.json'); const SKILLS_DIR = path.join(__dirname, '../skills'); const SCHEMA_ORIGIN = 'https://adcontextprotocol.org'; +// Active MCP role catalogs intentionally use explicit tool sets. Protocol +// tags alone are not sufficient: creative construction is historically tagged +// media-buy, while a seller-hosted media-buy role also needs account, task, +// governance, reporting, and separately-synced creative operations. +const MCP_ROLE_PROFILE_TOOLS = { + 'media-buy': [ + 'accept_proposal', + 'buy_products', + 'control_media_buy', + 'decline_proposals', + 'get_account_financials', + 'get_adcp_capabilities', + 'get_media_buy_delivery', + 'get_media_buys', + 'get_task_status', + 'list_accounts', + 'list_creatives', + 'list_products', + 'list_tasks', + 'log_event', + 'provide_performance_feedback', + 'refine_proposals', + 'report_usage', + 'request_proposals', + 'sync_accounts', + 'sync_agent_notification_configs', + 'sync_audiences', + 'sync_catalogs', + 'sync_creatives', + 'sync_event_sources', + 'sync_governance', + ], + creative: [ + 'build_creative', + 'get_account_financials', + 'get_adcp_capabilities', + 'get_creative_delivery', + 'get_creative_features', + 'get_task_status', + 'list_accounts', + 'list_creatives', + 'list_tasks', + 'list_transformers', + 'preview_creative', + 'report_usage', + 'sync_accounts', + 'sync_agent_notification_configs', + 'sync_catalogs', + 'sync_creatives', + 'sync_governance', + 'validate_input', + ], +}; +const MCP_ROLE_PROFILE_TASK_RESULT_OVERRIDES = { + 'media-buy': ['media_buy_delivery'], + creative: [], +}; + /** * Turn a source-form schema URI into its canonical published identity. * Source schemas intentionally use `/schemas/...` paths for local authoring; @@ -2169,6 +2227,11 @@ function generateMcpProjectionForVersion(versionDir, urlVersion) { canonicalManifest.adcp_version, ...Object.values(canonicalManifest.tools || {}).map(tool => tool.added_in).filter(Boolean), ].filter(version => semver.valid(version)).sort(semver.rcompare)[0]; + const isActiveProductionTool = tool => ( + tool.protocol !== 'compliance' + && (!tool.added_in || semver.lte(tool.added_in, surfaceVersion)) + && (!tool.deprecated_in || semver.gt(tool.deprecated_in, surfaceVersion)) + ); const productionTargetDir = path.join(targetDir, 'profiles', 'production'); const productionStats = generateMcpSchemaProjection({ sourceDir: SOURCE_DIR, @@ -2177,11 +2240,7 @@ function generateMcpProjectionForVersion(versionDir, urlVersion) { urlVersion, schemaUrlPrefix: `${urlVersion}/mcp/${MCP_PROTOCOL_VERSION}/profiles/production`, annotationMode: 'structural', - toolFilter: (_toolName, tool) => ( - tool.protocol !== 'compliance' - && (!tool.added_in || semver.lte(tool.added_in, surfaceVersion)) - && (!tool.deprecated_in || semver.gt(tool.deprecated_in, surfaceVersion)) - ), + toolFilter: (_toolName, tool) => isActiveProductionTool(tool), manifestMetadata: { profile: 'production', surface_version: surfaceVersion, @@ -2197,6 +2256,83 @@ function generateMcpProjectionForVersion(versionDir, urlVersion) { + `${productionStats.schemaCount} schemas, ` + `${(productionStats.totalBytes / (1024 * 1024)).toFixed(2)} MiB structural projection` ); + + for (const [profileName, toolNames] of Object.entries(MCP_ROLE_PROFILE_TOOLS)) { + const missingTools = toolNames.filter(toolName => !canonicalManifest.tools?.[toolName]); + if (missingTools.length > 0) { + throw new Error(`${profileName} MCP profile names unknown tools: ${missingTools.join(', ')}`); + } + const inactiveTools = toolNames.filter(toolName => ( + !isActiveProductionTool(canonicalManifest.tools[toolName]) + )); + if (inactiveTools.length > 0) { + throw new Error(`${profileName} MCP profile names inactive tools: ${inactiveTools.join(', ')}`); + } + + const selectedTools = new Set(toolNames); + const selectedTaskResultOverrides = new Set( + MCP_ROLE_PROFILE_TASK_RESULT_OVERRIDES[profileName] || [] + ); + const profileTargetDir = path.join(targetDir, 'profiles', profileName); + const profileStats = generateMcpSchemaProjection({ + sourceDir: SOURCE_DIR, + targetDir: profileTargetDir, + manifestPath, + urlVersion, + schemaUrlPrefix: `${urlVersion}/mcp/${MCP_PROTOCOL_VERSION}/profiles/${profileName}`, + annotationMode: 'structural', + toolFilter: (toolName, tool) => selectedTools.has(toolName) && isActiveProductionTool(tool), + taskResultOverrideFilter: taskType => selectedTaskResultOverrides.has(taskType), + manifestMetadata: { + profile: profileName, + profile_kind: 'active-role-catalog', + surface_version: surfaceVersion, + compatibility_scope: 'active-3.2-only', + filters: { + include_tools: toolNames, + exclude_deprecated: true, + }, + delivery: 'active 3.2 role-filtered validation artifacts; not a complete 3.x tools/list registration', + canonical_projection: '../../manifest.json', + }, + }); + if (profileStats.toolCount !== toolNames.length) { + throw new Error( + `${profileName} MCP profile generated ${profileStats.toolCount} tools; expected ${toolNames.length}` + ); + } + + const modelContextTargetDir = path.join(profileTargetDir, 'model-context'); + const modelContextStats = generateMcpSchemaProjection({ + sourceDir: SOURCE_DIR, + targetDir: modelContextTargetDir, + manifestPath, + urlVersion, + schemaUrlPrefix: `${urlVersion}/mcp/${MCP_PROTOCOL_VERSION}/profiles/${profileName}/model-context`, + annotationMode: 'structural', + schemaFields: ['inputSchema'], + toolFilter: (toolName, tool) => selectedTools.has(toolName) && isActiveProductionTool(tool), + manifestMetadata: { + profile: profileName, + profile_kind: 'active-role-catalog', + view: 'client-prompt-inputs', + surface_version: surfaceVersion, + compatibility_scope: 'active-3.2-only', + filters: { + include_tools: toolNames, + exclude_deprecated: true, + }, + delivery: 'client-side prompt input projection; servers continue to advertise outputSchema and clients validate with the parent profile', + validation_profile: '../manifest.json', + canonical_projection: '../../../manifest.json', + }, + }); + console.log( + ` ✓ ${profileName} ${surfaceVersion} profile: ${profileStats.toolCount} tools, ` + + `${(profileStats.totalBytes / (1024 * 1024)).toFixed(2)} MiB validation, ` + + `${(modelContextStats.totalBytes / 1024).toFixed(0)} KiB model context` + ); + } return stats; } @@ -2480,6 +2616,8 @@ async function main() { } module.exports = { + MCP_ROLE_PROFILE_TOOLS, + MCP_ROLE_PROFILE_TASK_RESULT_OVERRIDES, canonicalPublishedSchemaUri, canonicalizePublishedSchemaUris, generateExtensionRegistry, diff --git a/scripts/mcp-schema-projection.cjs b/scripts/mcp-schema-projection.cjs index dbf47bd964..711abc0dde 100644 --- a/scripts/mcp-schema-projection.cjs +++ b/scripts/mcp-schema-projection.cjs @@ -12,7 +12,13 @@ const SCHEMA_ORIGIN = 'https://adcontextprotocol.org'; const MAX_SCHEMA_DEPTH = 64; const MAX_SCHEMA_OBJECTS = 10_000; const MAX_SCHEMA_BYTES = 4 * 1024 * 1024; -const PRESENTATION_ANNOTATIONS = new Set(['$comment', 'description', 'examples', 'title']); +const PRESENTATION_ANNOTATIONS = new Set([ + '$comment', + 'description', + 'enumDescriptions', + 'examples', + 'title', +]); const POST_DRAFT_07_KEYWORDS = new Set([ '$anchor', @@ -576,9 +582,20 @@ function generateMcpSchemaProjection({ urlVersion, annotationMode = 'full', toolFilter = () => true, + schemaFields = ['inputSchema', 'outputSchema'], + taskResultOverrideFilter = () => true, manifestMetadata = {}, schemaUrlPrefix = `${urlVersion}/mcp/${MCP_PROTOCOL_VERSION}`, }) { + const supportedSchemaFields = new Set(['inputSchema', 'outputSchema']); + if ( + !Array.isArray(schemaFields) + || schemaFields.length === 0 + || new Set(schemaFields).size !== schemaFields.length + || schemaFields.some(field => !supportedSchemaFields.has(field)) + ) { + throw new Error('schemaFields must be a non-empty unique subset of inputSchema and outputSchema'); + } const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const projectedTools = {}; const generated = new Set(); @@ -589,45 +606,66 @@ function generateMcpSchemaProjection({ fs.rmSync(targetDir, { recursive: true, force: true }); fs.mkdirSync(targetDir, { recursive: true }); + function projectSchema(relativePath, label = relativePath) { + const sourcePath = path.join(sourceDir, relativePath); + if (!fs.existsSync(sourcePath)) { + throw new Error(`Missing source schema for ${label}: ${relativePath}`); + } + if (generated.has(relativePath)) return; + + const sourceSchema = JSON.parse(fs.readFileSync(sourcePath, 'utf8')); + let projectedSchema; + try { + projectedSchema = projectSourceSchema( + sourceSchema, + sourcePath, + sourceDir, + urlVersion, + relativePath, + annotationMode, + schemaUrlPrefix, + ); + } catch (error) { + throw new Error(`${relativePath}: ${error.message}`); + } + writeJson(path.join(targetDir, relativePath), projectedSchema); + const bytes = Buffer.byteLength(JSON.stringify(projectedSchema)); + totalBytes += bytes; + largestSchemaBytes = Math.max(largestSchemaBytes, bytes); + schemaCount++; + generated.add(relativePath); + } + for (const [toolName, tool] of Object.entries(manifest.tools || {})) { if (!toolFilter(toolName, tool)) continue; const projectedTool = { protocol: tool.protocol }; for (const [field, relativePath] of [ ['inputSchema', tool.request_schema], ['outputSchema', tool.response_schema], - ]) { - const sourcePath = path.join(sourceDir, relativePath); - if (!fs.existsSync(sourcePath)) { - throw new Error(`Missing source ${field} for ${toolName}: ${relativePath}`); - } - if (!generated.has(relativePath)) { - const sourceSchema = JSON.parse(fs.readFileSync(sourcePath, 'utf8')); - let projectedSchema; - try { - projectedSchema = projectSourceSchema( - sourceSchema, - sourcePath, - sourceDir, - urlVersion, - relativePath, - annotationMode, - schemaUrlPrefix, - ); - } catch (error) { - throw new Error(`${relativePath}: ${error.message}`); - } - writeJson(path.join(targetDir, relativePath), projectedSchema); - const bytes = Buffer.byteLength(JSON.stringify(projectedSchema)); - totalBytes += bytes; - largestSchemaBytes = Math.max(largestSchemaBytes, bytes); - schemaCount++; - generated.add(relativePath); - } + ].filter(([field]) => schemaFields.includes(field))) { + projectSchema(relativePath, `${toolName}.${field}`); projectedTool[field] = relativePath; } projectedTools[toolName] = projectedTool; } + let taskResultResolution; + if (schemaFields.includes('outputSchema') && manifest.task_result_resolution) { + const terminalSchemaOverrides = {}; + for (const [taskType, relativePath] of Object.entries( + manifest.task_result_resolution.terminal_schema_overrides || {} + )) { + if (!taskResultOverrideFilter(taskType, relativePath)) continue; + projectSchema(relativePath, `task result override ${taskType}`); + terminalSchemaOverrides[taskType] = relativePath; + } + taskResultResolution = { + discriminator_field: manifest.task_result_resolution.discriminator_field, + terminal_schema_pointer_template: '/tools/{task_type}/outputSchema', + terminal_schema_overrides: terminalSchemaOverrides, + }; + } + const projectionManifest = { mcp_protocol_version: MCP_PROTOCOL_VERSION, schema_dialect: JSON_SCHEMA_2020_12, @@ -635,6 +673,8 @@ function generateMcpSchemaProjection({ compatibility: 'semantics-preserving projection; no 4.0 strictness rules applied', delivery: 'downloadable schema artifacts; servers choose which schemas to embed in tools/list', annotation_mode: annotationMode, + schema_fields: schemaFields, + ...(taskResultResolution ? { task_result_resolution: taskResultResolution } : {}), ...manifestMetadata, tools: projectedTools, }; diff --git a/tests/mcp-schema-projection.test.cjs b/tests/mcp-schema-projection.test.cjs index 896a02455b..60d0712981 100644 --- a/tests/mcp-schema-projection.test.cjs +++ b/tests/mcp-schema-projection.test.cjs @@ -14,6 +14,8 @@ const { normalizeSubstitutions, } = require('../scripts/lint-storyboard-sample-request-schema.cjs'); const { + MCP_ROLE_PROFILE_TOOLS, + MCP_ROLE_PROFILE_TASK_RESULT_OVERRIDES, buildTaskResultResolution, validateManifestToolRelationships, } = require('../scripts/build-schemas.cjs'); @@ -140,6 +142,7 @@ test('structural presentation mode removes only schema annotations', () => { const source = { title: 'Request title', description: 'Request description', + enumDescriptions: { value: 'Display label' }, type: 'object', properties: { payload: { @@ -446,6 +449,7 @@ test('generated MCP projection covers every tool within AdCP safety bounds', () assert.equal(projectionManifest.mcp_protocol_version, MCP_PROTOCOL_VERSION); assert.equal(projectionManifest.schema_dialect, JSON_SCHEMA_2020_12); assert.equal(projectionManifest.annotation_mode, 'full'); + assert.deepEqual(projectionManifest.schema_fields, ['inputSchema', 'outputSchema']); assert.match(projectionManifest.delivery, /downloadable schema artifacts/); assert.deepEqual(canonicalManifest.task_result_resolution, { discriminator_field: 'task_type', @@ -454,6 +458,17 @@ test('generated MCP projection covers every tool within AdCP safety bounds', () media_buy_delivery: 'media-buy/media-buy-delivery-webhook-result.json', }, }); + assert.deepEqual(projectionManifest.task_result_resolution, { + discriminator_field: 'task_type', + terminal_schema_pointer_template: '/tools/{task_type}/outputSchema', + terminal_schema_overrides: { + media_buy_delivery: 'media-buy/media-buy-delivery-webhook-result.json', + }, + }); + assert.ok(fs.existsSync(path.join( + PROJECTION_DIR, + projectionManifest.task_result_resolution.terminal_schema_overrides.media_buy_delivery + ))); const taskTypes = readJson(path.join(SOURCE_DIR, 'enums', 'task-type.json')).enum; for (const taskType of taskTypes) { const selectedSchema = canonicalManifest.task_result_resolution.terminal_schema_overrides[taskType] @@ -750,3 +765,98 @@ test('generated production profile exposes the active 3.2 surface without compli } assert.ok(profileBytes < canonicalBytes * 0.65, `${profileBytes} should be materially smaller than ${canonicalBytes}`); }); + +test('generated role profiles are active validation catalogs with bounded model-context views', () => { + const canonicalManifest = readJson(path.join(LATEST_DIR, 'manifest.json')); + + for (const [profileName, expectedTools] of Object.entries(MCP_ROLE_PROFILE_TOOLS)) { + const profileDir = path.join(PROJECTION_DIR, 'profiles', profileName); + const modelContextDir = path.join(profileDir, 'model-context'); + const profile = readJson(path.join(profileDir, 'manifest.json')); + const modelContext = readJson(path.join(modelContextDir, 'manifest.json')); + + assert.equal(profile.profile, profileName); + assert.equal(profile.profile_kind, 'active-role-catalog'); + assert.equal(profile.surface_version, '3.2.0'); + assert.equal(profile.compatibility_scope, 'active-3.2-only'); + assert.equal(profile.annotation_mode, 'structural'); + assert.deepEqual(profile.schema_fields, ['inputSchema', 'outputSchema']); + assert.deepEqual(profile.filters, { + include_tools: expectedTools, + exclude_deprecated: true, + }); + assert.deepEqual(Object.keys(profile.tools).sort(), [...expectedTools].sort()); + + assert.equal(modelContext.profile, profileName); + assert.equal(modelContext.view, 'client-prompt-inputs'); + assert.equal(modelContext.validation_profile, '../manifest.json'); + assert.equal( + path.resolve(modelContextDir, modelContext.validation_profile), + path.join(profileDir, 'manifest.json') + ); + assert.deepEqual(modelContext.schema_fields, ['inputSchema']); + assert.deepEqual(Object.keys(modelContext.tools).sort(), [...expectedTools].sort()); + + const expectedOverrides = Object.fromEntries( + MCP_ROLE_PROFILE_TASK_RESULT_OVERRIDES[profileName].map(taskType => [ + taskType, + canonicalManifest.task_result_resolution.terminal_schema_overrides[taskType], + ]) + ); + assert.deepEqual(profile.task_result_resolution, { + discriminator_field: 'task_type', + terminal_schema_pointer_template: '/tools/{task_type}/outputSchema', + terminal_schema_overrides: expectedOverrides, + }); + for (const relativePath of Object.values(expectedOverrides)) { + assert.ok(fs.existsSync(path.join(profileDir, relativePath))); + } + assert.equal(modelContext.task_result_resolution, undefined); + + let modelContextBytes = 0; + for (const toolName of expectedTools) { + const fullTool = profile.tools[toolName]; + const modelTool = modelContext.tools[toolName]; + assert.equal(fullTool.protocol, canonicalManifest.tools[toolName].protocol); + assert.equal(modelTool.protocol, fullTool.protocol); + assert.equal(modelTool.inputSchema, fullTool.inputSchema); + assert.equal(modelTool.outputSchema, undefined); + assert.ok(fs.existsSync(path.join(profileDir, fullTool.inputSchema))); + assert.ok(fs.existsSync(path.join(profileDir, fullTool.outputSchema))); + const modelInputPath = path.join(modelContextDir, modelTool.inputSchema); + assert.ok(fs.existsSync(modelInputPath)); + modelContextBytes += Buffer.byteLength(JSON.stringify(readJson(modelInputPath))); + } + assert.ok( + modelContextBytes < 384 * 1024, + `${profileName} model-context inputs exceed 384 KiB: ${modelContextBytes}` + ); + } + + const mediaBuyTools = new Set(MCP_ROLE_PROFILE_TOOLS['media-buy']); + const activeMediaBuyTools = Object.entries(canonicalManifest.tools) + .filter(([, tool]) => tool.protocol === 'media-buy') + .filter(([, tool]) => !tool.deprecated_in || tool.deprecated_in > '3.2.0') + .map(([toolName]) => toolName) + .filter(toolName => toolName !== 'build_creative'); + for (const toolName of activeMediaBuyTools) assert.ok(mediaBuyTools.has(toolName), toolName); + assert.ok(mediaBuyTools.has('sync_creatives')); + assert.ok(mediaBuyTools.has('sync_governance')); + assert.ok(mediaBuyTools.has('provide_performance_feedback')); + assert.ok(!mediaBuyTools.has('build_creative')); + for (const compatibilityFacade of ['get_products', 'create_media_buy', 'update_media_buy']) { + assert.ok(!mediaBuyTools.has(compatibilityFacade)); + assert.ok(canonicalManifest.tools[compatibilityFacade].deprecated_in); + } + + const creativeTools = new Set(MCP_ROLE_PROFILE_TOOLS.creative); + const activeCreativeTools = Object.entries(canonicalManifest.tools) + .filter(([, tool]) => tool.protocol === 'creative') + .filter(([, tool]) => !tool.deprecated_in || tool.deprecated_in > '3.2.0') + .map(([toolName]) => toolName); + for (const toolName of activeCreativeTools) assert.ok(creativeTools.has(toolName), toolName); + assert.ok(creativeTools.has('build_creative')); + assert.ok(creativeTools.has('sync_catalogs')); + assert.ok(creativeTools.has('sync_creatives')); + assert.ok(!creativeTools.has('list_products')); +}); From 17a03ca9ca1a6cae6366054fca95bb6dc08bd7d5 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 12 Aug 2026 22:18:22 +0200 Subject: [PATCH 40/49] fix(media-buy): restore proposal inventory holds --- .changeset/secure-get-products-idempotency.md | 4 +- docs/building/by-layer/L3/error-handling.mdx | 4 +- .../cross-cutting/version-adaptation.mdx | 6 +- .../product-discovery/media-products.mdx | 2 +- .../task-reference/accept_proposal.mdx | 4 +- .../task-reference/control_media_buy.mdx | 2 +- .../task-reference/create_media_buy.mdx | 12 +- docs/media-buy/task-reference/index.mdx | 6 +- .../task-reference/refine_proposals.mdx | 33 +- .../task-reference/request_proposals.mdx | 10 +- docs/reference/release-notes.mdx | 4 +- docs/snippets/compliance-error-codes.mdx | 28 +- scripts/error-code-drift-dispositions.json | 5 - server/src/training-agent/source-schema.ts | 17 + server/src/training-agent/state.ts | 2 + server/src/training-agent/task-handlers.ts | 433 +++++++++++++++--- .../tenants/tenant-smoke.test.ts | 7 +- server/src/training-agent/types.ts | 9 + .../product-discovery-schema-parity.test.ts | 9 +- .../unit/training-agent-idempotency.test.ts | 60 +++ server/tests/unit/training-agent.test.ts | 163 ++++++- .../source/core/canonical-proposal.json | 11 +- .../schemas/source/core/x-entity-types.json | 2 +- static/schemas/source/enums/error-code.json | 18 +- .../schemas/source/enums/proposal-status.json | 6 +- static/schemas/source/enums/task-type.json | 2 +- .../media-buy/create-media-buy-request.json | 2 +- .../media-buy/get-products-request.json | 2 +- .../source/media-buy/proposal-refinement.json | 28 +- .../media-buy/refine-proposals-request.json | 22 +- .../media-buy/refine-proposals-response.json | 80 +++- .../media-buy/request-proposals-request.json | 2 +- .../media-buy/request-proposals-response.json | 19 +- .../get-adcp-capabilities-response.json | 2 +- tests/composed-schema-validation.test.cjs | 83 +++- 35 files changed, 908 insertions(+), 191 deletions(-) diff --git a/.changeset/secure-get-products-idempotency.md b/.changeset/secure-get-products-idempotency.md index 49e31ad757..c89d74a1f0 100644 --- a/.changeset/secure-get-products-idempotency.md +++ b/.changeset/secure-get-products-idempotency.md @@ -2,6 +2,6 @@ "adcontextprotocol": minor --- -Add the compact AdCP 3.2 product and MediaBuy lifecycle: `list_products`, `request_proposals`, `refine_proposals`, `decline_proposals`, `buy_products`, `accept_proposal`, and `control_media_buy`. The task-specific contracts separate offer discovery, executable proposal creation, immutable refinement, terminal decline, direct purchase, proposal acceptance, and operational delivery control while retaining `get_products`, `create_media_buy`, and `update_media_buy` as compatibility facades throughout 3.x. New purchase and control inputs never accept inline creatives; commercial amendments and negotiated cancellations fork an accepted proposal, while operational controls remain revision-checked. Compact purchase snapshots preserve resolved package flight, billing-measurement, performance, and reporting terms. A shared opportunity reference connects planning-cycle context through purchase without duplicating proposal version identity. Canonical inputs use stable brand keys, catalog references, and compact account and optimization types so brand assets, legacy named formats, creative provenance, and compliance payloads do not transitively enter the clean tools. Publish machine-readable SDK fallback grades and task-result schema resolution, plus MCP production, media-buy, and creative catalogs that remove presentation annotations without changing validation semantics. The role catalogs select active 3.2 seller-hosted operations and publish client-side input-only prompt views while retaining output and terminal task-result schemas in their parent validation catalogs. +Add the compact AdCP 3.2 product and MediaBuy lifecycle: `list_products`, `request_proposals`, `refine_proposals`, `decline_proposals`, `buy_products`, `accept_proposal`, and `control_media_buy`. The task-specific contracts separate offer discovery, immutable draft proposal creation, explicit finalization with inventory reservation, terminal decline, direct purchase, proposal acceptance, and operational delivery control while retaining `get_products`, `create_media_buy`, and `update_media_buy` as compatibility facades throughout 3.x. New purchase and control inputs never accept inline creatives; commercial amendments and negotiated cancellations fork an accepted proposal, while operational controls remain revision-checked. Compact purchase snapshots preserve resolved package flight, billing-measurement, performance, and reporting terms. A shared opportunity reference connects planning-cycle context through purchase without duplicating proposal version identity. Canonical inputs use stable brand keys, catalog references, and compact account and optimization types so brand assets, legacy named formats, creative provenance, and compliance payloads do not transitively enter the clean tools. Publish machine-readable SDK fallback grades and task-result schema resolution, plus MCP production, media-buy, and creative catalogs that remove presentation annotations without changing validation semantics. The role catalogs select active 3.2 seller-hosted operations and publish client-side input-only prompt views while retaining output and terminal task-result schemas in their parent validation catalogs. -Document the normative `PROPOSAL_UNAVAILABLE` rejection path when a seller did not reserve inventory, add `countries` and `property_list` product-attribute filters, and publish compact task-specific async envelopes for consultative proposal planning and re-underwriting. +Preserve the AdCP 3.1 inventory-reservation contract in the compact lifecycle: requested and revised proposals remain immutable drafts until `refine_proposals` finalizes them, and a finalized `committed` snapshot guarantees inventory is held until `expires_at`. Add `countries` and `property_list` product-attribute filters, and publish compact task-specific async envelopes for consultative proposal planning and re-underwriting. diff --git a/docs/building/by-layer/L3/error-handling.mdx b/docs/building/by-layer/L3/error-handling.mdx index 498bd6835f..146a10de36 100644 --- a/docs/building/by-layer/L3/error-handling.mdx +++ b/docs/building/by-layer/L3/error-handling.mdx @@ -493,8 +493,8 @@ The wire-level `recovery: "correctable"` on the sandbox-only path is the registe |------|----------|-------------|------------| | `PRODUCT_NOT_FOUND` | correctable | Referenced product IDs are unknown or expired | Remove invalid IDs, or re-discover with [`get_products`](/docs/media-buy/task-reference/get_products) | | [`PRODUCT_UNAVAILABLE`](/docs/building/verification/compliance-catalog#error-code-product-unavailable) | correctable | Product is sold out or no longer available | Choose a different product | -| [`PROPOSAL_EXPIRED`](/docs/building/verification/compliance-catalog#error-code-proposal-expired) | correctable | Referenced proposal has passed its `expires_at` | Call [`request_proposals`](/docs/media-buy/task-reference/request_proposals) to get a fresh executable proposal | -| `PROPOSAL_NOT_FOUND` | correctable | `proposal_id` is unknown to the seller (never issued, wrong tenant, or evicted from cache) | Call [`request_proposals`](/docs/media-buy/task-reference/request_proposals) to obtain a fresh executable proposal, then retry | +| [`PROPOSAL_EXPIRED`](/docs/building/verification/compliance-catalog#error-code-proposal-expired) | correctable | Referenced proposal has passed its `expires_at` | Call [`request_proposals`](/docs/media-buy/task-reference/request_proposals), then finalize a fresh proposal | +| `PROPOSAL_NOT_FOUND` | correctable | `proposal_id` is unknown to the seller (never issued, wrong tenant, or evicted from cache) | Request and finalize a fresh proposal, then retry | | [`MULTI_FINALIZE_UNSUPPORTED`](/docs/building/verification/compliance-catalog#error-code-multi-finalize-unsupported) | correctable | A legacy `get_products` request attempted to finalize multiple proposals where the seller cannot guarantee atomic commitment | Sequence single-proposal legacy `get_products` finalize calls | | [`REQUOTE_REQUIRED`](/docs/building/verification/compliance-catalog#error-code-requote-required) | correctable | Requested update falls outside the envelope (budget, dates, volume, targeting) the original quote was priced against; `pricing_option` remains locked | Adjust the update to fit the current quote, rediscover products/terms, add packages when available, or create a separate media buy. 3.1 does not define an amendment-quote artifact for [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy). | | `SIGNAL_NOT_FOUND` | correctable | Referenced signal does not exist in the catalog | Verify `signal_id` via [`get_signals`](/docs/signals/tasks/get_signals), or confirm availability from this agent | diff --git a/docs/building/cross-cutting/version-adaptation.mdx b/docs/building/cross-cutting/version-adaptation.mdx index 6115d12e92..f7e9d09c95 100644 --- a/docs/building/cross-cutting/version-adaptation.mdx +++ b/docs/building/cross-cutting/version-adaptation.mdx @@ -67,8 +67,8 @@ AdCP 3.2's compact lifecycle replaces the broad `get_products`, `create_media_bu | Current SDK method | Legacy peer tool | Fallback mode | Required behavior | |---|---|---|---| | `listProducts` | `get_products` wholesale | `orchestrated` | Translate the common case directly; when exact `product_ids` are requested, page and filter until every requested ID is found or the feed is exhausted | -| `requestProposals` | `get_products` brief, then finalize when needed | `orchestrated` | Return only executable committed snapshots; resume the same sequence safely on retry | -| `refineProposals` | `get_products` refine, then finalize when needed | `orchestrated` | Mint and return executable immutable revisions with source-result correspondence | +| `requestProposals` | `get_products` brief | `orchestrated` | Return immutable draft snapshots with source IDs preserved | +| `refineProposals` | `get_products` refine or proposal finalize | `orchestrated` | Mint draft revisions or committed held snapshots with source-result correspondence and atomic finalize batches | | `declineProposals` | none | `none` | Throw a typed capability error before sending a call; never silently discard terminal feedback | | `buyProducts` | `create_media_buy` explicit packages | `orchestrated` | Translate product purchases to packages without adding creatives, then materialize the accepted proposal snapshot expected by the current result | | `acceptProposal` | `create_media_buy` proposal mode | `orchestrated` | Execute a new-buy proposal directly; amendment/cancellation proposals require an SDK-managed compatibility workflow or a typed capability error when the legacy seller cannot preserve it | @@ -76,6 +76,8 @@ AdCP 3.2's compact lifecycle replaces the broad `get_products`, `create_media_bu Client SDKs SHOULD expose the current methods as their primary API and choose the peer path from `media_buy.lifecycle_tools`. A directly advertised current tool always wins. A `direct` fallback may run transparently. An `orchestrated` fallback may run transparently only when the SDK ships a handwritten adapter that preserves the current tool's result completeness, terminal state, atomicity, and idempotency semantics; the manifest classification does not synthesize that state machine. In particular, SDKs adapting a direct 3.x creation MUST retain the accepted commercial snapshot locally when the legacy seller cannot return it, and MUST not pretend a legacy seller supports proposal-based amendments. Each underlying legacy mutation needs its own deterministic replay identity, and retrying the current SDK call must resume rather than restart the sequence. Otherwise the SDK MUST throw a typed `CapabilityUnsupportedError` carrying `requested_tool`, the peer's advertised tools, and `fallback_mode`. +For proposal finalization, the adapter MUST retain a durable mapping from the current successor `proposal_id` to the legacy source ID that `get_products` finalized. A later `acceptProposal` sends the mapped legacy ID to `create_media_buy` while returning and retaining the current immutable successor snapshot. The mapping is part of the adapter's replay state: losing it, reusing the source ID as the current ID, or minting a second successor on retry violates the 3.2 contract. + These tools deliberately retain distinct `operation_family` values. Fallback metadata describes SDK adaptation, not cross-name authorization, idempotency equivalence, task recovery, or webhook identity. A client adapter MUST be authorized for the actual legacy call; a grant for a split tool does not transfer across names. Server SDKs may implement the current lifecycle once and derive the deprecated facades for 3.x callers, but may advertise only behavior the adapter actually preserves. ## Mechanism 2 — Migrate SDK majors via co-existence diff --git a/docs/media-buy/product-discovery/media-products.mdx b/docs/media-buy/product-discovery/media-products.mdx index 5f06375056..f54ef40bba 100644 --- a/docs/media-buy/product-discovery/media-products.mdx +++ b/docs/media-buy/product-discovery/media-products.mdx @@ -1144,7 +1144,7 @@ To execute a committed proposal, provide the `proposal_id` and `total_budget` in For a fixed proposal, the publisher converts allocation percentages into packages: - `ch_desktop_de`: 20% × \$50,000 = \$10,000 -The split 3.2 [`request_proposals`](/docs/media-buy/task-reference/request_proposals) and [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) tasks return executable immutable snapshots with firm terms and any inventory hold. `create_media_buy(proposal_id)` is the acceptance/execution step. Legacy `get_products` may still return drafts; sellers reject attempts to execute one with `PROPOSAL_NOT_COMMITTED`, and its finalize form remains supported throughout 3.x. +The split 3.2 [`request_proposals`](/docs/media-buy/task-reference/request_proposals) task returns immutable drafts. [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) either creates another draft revision or, with `action: "finalize"`, returns a committed snapshot with inventory held until `expires_at`. [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal) is the acceptance/execution step. Legacy `get_products` retains its equivalent finalize form throughout 3.x. - `ch_desktop_fr`: 30% × \$50,000 = \$15,000 - etc. diff --git a/docs/media-buy/task-reference/accept_proposal.mdx b/docs/media-buy/task-reference/accept_proposal.mdx index bccdc2a8eb..3a084678d9 100644 --- a/docs/media-buy/task-reference/accept_proposal.mdx +++ b/docs/media-buy/task-reference/accept_proposal.mdx @@ -7,6 +7,8 @@ testable: true `accept_proposal` is the single terminal action for the compact proposal lifecycle. It accepts exactly one committed immutable proposal snapshot. +`committed` retains the AdCP 3.1 reservation guarantee: the seller has locked the proposal's commercial terms and reserved its inventory until `expires_at`. Finalization creates that hold; acceptance consumes it and creates, amends, or cancels the MediaBuy. + Depending on `proposal_kind`, acceptance: - creates a new MediaBuy; @@ -32,4 +34,4 @@ If the proposal belongs to an `opportunity`, acceptance closes that planning cyc Success returns the resulting MediaBuy identity, the proposal with `proposal_status: "accepted"`, and `purchase_bindings[]` mapping proposal purchase positions to package IDs for later creative assignment. Acceptance is idempotent. The seller atomically verifies `proposal_id`, `proposal_terms_digest`, expiry, and any `base_media_buy_revision`; stale or mismatched acceptance never applies different terms. -A seller that did not reserve inventory at proposal time MAY reject an unexpired committed proposal with [`PROPOSAL_UNAVAILABLE`](/docs/building/verification/compliance-catalog#error-code-proposal-unavailable). Inventory availability risk before acceptance is on the buyer unless the proposal explicitly commits a hold. Buyers that require reservation guarantees should request a hold in the [`request_proposals`](/docs/media-buy/task-reference/request_proposals) brief. +An unexpired committed proposal cannot be rejected merely because its reserved inventory was sold elsewhere. Once `expires_at` passes, the hold lapses and the seller returns [`PROPOSAL_EXPIRED`](/docs/building/verification/compliance-catalog#error-code-proposal-expired); the buyer must request and finalize a fresh proposal. diff --git a/docs/media-buy/task-reference/control_media_buy.mdx b/docs/media-buy/task-reference/control_media_buy.mdx index d1141dea9c..c72c1a93cb 100644 --- a/docs/media-buy/task-reference/control_media_buy.mdx +++ b/docs/media-buy/task-reference/control_media_buy.mdx @@ -35,7 +35,7 @@ It does not accept creatives, creative assignments, new products/packages, fligh } ``` -When an otherwise valid control would exceed the accepted budget, targeting, or delivery envelope, the seller returns [`REQUOTE_REQUIRED`](/docs/building/verification/compliance-catalog#error-code-requote-required). The buyer reads `accepted_proposal_id` from [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys), passes it to [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals), receives a new committed amendment proposal, and applies it through [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal). This pointer survives SDK restart and moves atomically after each accepted amendment. +When an otherwise valid control would exceed the accepted budget, targeting, or delivery envelope, the seller returns [`REQUOTE_REQUIRED`](/docs/building/verification/compliance-catalog#error-code-requote-required). The buyer reads `accepted_proposal_id` from [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys), passes it to [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) to create a draft amendment, finalizes that draft into a committed hold, and applies it through [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal). This pointer survives SDK restart and moves atomically after each accepted amendment. `canceled: true` is direct only when the accepted cancellation policy already grants the caller that right. A cancellation requiring counterparty agreement uses `refine_proposals` with `change_kind: "cancellation"`. diff --git a/docs/media-buy/task-reference/create_media_buy.mdx b/docs/media-buy/task-reference/create_media_buy.mdx index d95a7406f9..e5dba2f326 100644 --- a/docs/media-buy/task-reference/create_media_buy.mdx +++ b/docs/media-buy/task-reference/create_media_buy.mdx @@ -145,7 +145,7 @@ npx @adcp/sdk@latest \ | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `account` | [account-ref](/docs/building/by-layer/L2/accounts-and-agents#account-references) | Yes | Account reference. Pass `{ "account_id": "..." }` or `{ "brand": {...}, "operator": "..." }` if the seller supports implicit resolution. Required for billing and policy evaluation. | -| `proposal_id` | string | No* | ID of the exact executable proposal snapshot from [`request_proposals`](/docs/media-buy/task-reference/request_proposals) or [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals). Declined or already executed proposals fail with [`INVALID_STATE`](/docs/building/verification/compliance-catalog#error-code-invalid-state). Exact retries reuse the original idempotency key and replay success. Legacy [`get_products`](/docs/media-buy/task-reference/get_products) drafts fail with [`PROPOSAL_NOT_COMMITTED`](/docs/building/verification/compliance-catalog#error-code-proposal-not-committed) until finalized through that compatibility task. | +| `proposal_id` | string | No* | ID of the exact committed proposal snapshot produced by [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) with `action: "finalize"`, or by the legacy [`get_products`](/docs/media-buy/task-reference/get_products) finalize action. Drafts fail with [`PROPOSAL_NOT_COMMITTED`](/docs/building/verification/compliance-catalog#error-code-proposal-not-committed); declined or already executed proposals fail with [`INVALID_STATE`](/docs/building/verification/compliance-catalog#error-code-invalid-state). Exact retries reuse the original idempotency key and replay success. | | `opportunity` | OpportunityContext | No | Proposal-mode planning-cycle closure shared with [`request_proposals`](/docs/media-buy/task-reference/request_proposals) and [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals); requires `proposal_id`. Omitting status is the create-specific signal to infer closed with `accepted_with_seller`; if status is sent, it must explicitly carry that closure. | | `total_budget` | TotalBudget | No* | Hard aggregate lifetime budget. Its currency is the single media-buy denomination for package constraints and canonical bidding. Required for proposals and seller-optimized explicit packages. Optional in fixed explicit-package mode; when supplied there, it must equal the sum of package budgets. | | `budget_allocation` | BudgetAllocation | No | Cross-package allocation mode. Omit for fixed allocation. Use `seller_optimized` with media-buy optimization goals to delegate allocation to the seller. Must be omitted when executing a proposal because the committed proposal supplies it. | @@ -164,9 +164,9 @@ npx @adcp/sdk@latest \ \* Either `packages` OR (`proposal_id` + `total_budget`) must be provided. -When executing a proposal through this 3.x compatibility facade, `proposal_status` determines whether `create_media_buy` is valid. The split 3.2 request and refine tasks return `committed` snapshots that can be accepted before `expires_at`; new callers use [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal). Legacy [`get_products`](/docs/media-buy/task-reference/get_products) drafts require its finalize form, which remains supported throughout 3.x. +When executing a proposal through this 3.x compatibility facade, `proposal_status` determines whether `create_media_buy` is valid. The split 3.2 request and revise operations return drafts; `refine_proposals` with `action: "finalize"` returns the `committed` snapshot that can be accepted before `expires_at`. New callers use [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal). Legacy [`get_products`](/docs/media-buy/task-reference/get_products) drafts require its finalize form, which remains supported throughout 3.x. -A seller that did not reserve inventory at proposal time MAY reject an unexpired `committed` proposal at execution with [`PROPOSAL_UNAVAILABLE`](/docs/building/verification/compliance-catalog#error-code-proposal-unavailable). Inventory availability risk pre-execution is on the buyer unless the seller explicitly commits a hold in the proposal terms. Buyers that need reservation guarantees should request a hold in the `request_proposals` brief. +An unexpired `committed` proposal carries an inventory hold and cannot be rejected merely because the seller sold that reserved inventory elsewhere. After `expires_at`, the hold lapses and the seller returns [`PROPOSAL_EXPIRED`](/docs/building/verification/compliance-catalog#error-code-proposal-expired). ### TotalBudget Object @@ -895,7 +895,7 @@ asyncio.run(create_with_reporting()) ### Executing a Proposal -Execute a committed proposal from [`request_proposals`](/docs/media-buy/task-reference/request_proposals) or [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) without manually constructing packages: +Execute a committed proposal finalized through [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) without manually constructing packages: @@ -908,7 +908,7 @@ const endDate = new Date(); endDate.setDate(endDate.getDate() + 90); const result = await testAgent.createMediaBuy({ - proposal_id: 'swiss_balanced_v1', // From request_proposals or refine_proposals + proposal_id: 'swiss_balanced_v1', // From refine_proposals action: "finalize" total_budget: { amount: 50000, currency: 'USD' @@ -947,7 +947,7 @@ async def execute_proposal(): end_date = datetime.now(timezone.utc) + timedelta(days=90) result = await test_agent.simple.create_media_buy( - proposal_id='swiss_balanced_v1', # From request_proposals or refine_proposals + proposal_id='swiss_balanced_v1', # From refine_proposals action: "finalize" total_budget={ 'amount': 50000, 'currency': 'USD' diff --git a/docs/media-buy/task-reference/index.mdx b/docs/media-buy/task-reference/index.mdx index 96e8d2664d..8e34abd022 100644 --- a/docs/media-buy/task-reference/index.mdx +++ b/docs/media-buy/task-reference/index.mdx @@ -15,7 +15,7 @@ Complete reference for all AdCP Media Buy tasks. Each task is designed for AI ag | [`get_products`](/docs/media-buy/task-reference/get_products) | Discover inventory and refine products | ~60s | Discovery | | [`list_products`](/docs/media-buy/task-reference/list_products) | Enumerate structured product offers | ~1s | Discovery | | [`request_proposals`](/docs/media-buy/task-reference/request_proposals) | Request seller-authored plans | ~60s | Planning | -| [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) | Create immutable proposal revisions | ~60s | Planning | +| [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) | Revise proposals or finalize inventory holds | ~60s | Planning | | [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals) | Record terminal proposal feedback | ~1s | Planning | | [`buy_products`](/docs/media-buy/task-reference/buy_products) | Buy published offers directly | Minutes-Days | Media Buys | | [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal) | Accept new-buy, amendment, or cancellation terms | Minutes-Days | Media Buys | @@ -73,7 +73,7 @@ Start here to understand what's available and plan your campaign. - **[`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities)** - Discover agent capabilities, portfolio, and supported features (protocol-level task) - **[`get_products`](/docs/media-buy/task-reference/get_products)** - The core discovery task using natural language briefs - **[`list_products`](/docs/media-buy/task-reference/list_products)** - Compact structured product-offer reads -- **[`request_proposals`](/docs/media-buy/task-reference/request_proposals)**, **[`refine_proposals`](/docs/media-buy/task-reference/refine_proposals)**, and **[`decline_proposals`](/docs/media-buy/task-reference/decline_proposals)** - Explicit proposal lifecycle operations; requested and refined proposals are executable snapshots +- **[`request_proposals`](/docs/media-buy/task-reference/request_proposals)**, **[`refine_proposals`](/docs/media-buy/task-reference/refine_proposals)**, and **[`decline_proposals`](/docs/media-buy/task-reference/decline_proposals)** - Explicit proposal lifecycle operations; request and revision produce drafts, while finalization creates a committed inventory hold - **[Canonical formats](/docs/creative/canonical-formats)** - Understand creative requirements and authority ### Media Buy Management @@ -131,7 +131,7 @@ Schemas are accessible at runtime via the documentation server for validation an Task names use snake_case and follow verb-first semantics consistently across Media Buy: - `get_*`: Retrieve current state or scoped datasets (for example [`get_products`](/docs/media-buy/task-reference/get_products), [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys), [`get_media_buy_delivery`](/docs/media-buy/task-reference/get_media_buy_delivery)) - `list_*`: Enumerate collections with optional filtering (for example [`list_creatives`](/docs/creative/task-reference/list_creatives)) -- `request_*`, `refine_*`, and `decline_*`: Explicit lifecycle transitions over immutable resources; executable proposals flow directly into [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) +- `request_*`, `refine_*`, and `decline_*`: Explicit lifecycle transitions over immutable resources; finalized proposals flow into [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal) - `create_*`: Create new resources ([`create_media_buy`](/docs/media-buy/task-reference/create_media_buy)) - `update_*`: Apply partial updates to existing resources ([`update_media_buy`](/docs/media-buy/task-reference/update_media_buy)) - `sync_*`: Reconcile external state into seller systems with upsert-like behavior ([`sync_catalogs`](/docs/media-buy/task-reference/sync_catalogs), [`sync_creatives`](/docs/creative/task-reference/sync_creatives), [`sync_event_sources`](/docs/media-buy/task-reference/sync_event_sources)) diff --git a/docs/media-buy/task-reference/refine_proposals.mdx b/docs/media-buy/task-reference/refine_proposals.mdx index f1ab7b4fdd..6b21cdd8dc 100644 --- a/docs/media-buy/task-reference/refine_proposals.mdx +++ b/docs/media-buy/task-reference/refine_proposals.mdx @@ -1,11 +1,16 @@ --- title: refine_proposals -description: "Create executable revisions from one or more proposals." +description: "Revise proposals or finalize drafts into inventory holds." "og:title": "AdCP refine_proposals Task" testable: false --- -`refine_proposals` creates executable committed revisions directly. Each entry targets one draft, committed, or accepted proposal. Proposal IDs must be unique within the request, and results preserve request order. Successful refinement returns a new `proposal_id`; that ID is the complete version identity for the immutable commercial snapshot, and the source remains unchanged. +`refine_proposals` has two explicit operations over immutable proposal snapshots: + +- `action: "revise"` (the default) applies `instructions` and returns a new draft proposal. +- `action: "finalize"` changes no commercial terms and returns a new committed proposal backed by an inventory hold until `expires_at`. + +Revision may target a draft, committed, or accepted proposal; finalization must target a draft. Proposal IDs must be unique within the request, and results preserve request order. Every successful operation returns a new `proposal_id`; that ID is the complete version identity for the immutable commercial snapshot, and the source remains unchanged. **Request schema:** [`/schemas/v3/media-buy/refine-proposals-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/refine-proposals-request.json) @@ -25,7 +30,25 @@ testable: false } ``` -The response contains one ordered `results[]` entry per request. `revised` means the requested change was applied, `partial` returns an honest executable revision plus explanatory notes, and `unable` returns a reason without minting a misleading revision. Every returned revision carries typed `commercial_terms` and a `terms_digest`. Pass a new-buy proposal to [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal), or use [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals) when the buyer stops pursuing it. The deprecated [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) proposal mode remains only as the 3.x compatibility adapter. +The response contains one ordered `results[]` entry per request. `revised` means the requested change produced a new draft, `partial` returns an honest draft plus explanatory notes, `finalized` returns a committed held snapshot, and `unable` returns a reason without minting a misleading result. Every returned proposal carries typed `commercial_terms` and a `terms_digest`. Pass only a finalized committed proposal to [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal), or use [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals) when the buyer stops pursuing it. The deprecated [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) proposal mode remains only as the 3.x compatibility adapter. + +## Finalize and hold inventory + +Finalization is seller commitment, not buyer acceptance. It locks the already-negotiated terms and reserves inventory until the returned `expires_at`: + +```json +{ + "idempotency_key": "550e8400-e29b-41d4-a716-446655441003", + "refinements": [ + { + "proposal_id": "proposal_draft_789", + "action": "finalize" + } + ] +} +``` + +A request containing `finalize` must contain only finalize entries. Sellers validate the complete batch before creating any holds; multi-proposal finalization is atomic. Exact retries use the same idempotency key. A new key does not extend or recreate an existing hold. Every revised proposal inherits the source proposal's opportunity association. Callers do not repeat opportunity context during refinement. @@ -46,6 +69,6 @@ An accepted proposal is the immutable commercial envelope attached to its MediaB } ``` -Use `change_kind: "cancellation"` when cancellation requires counterparty agreement. The returned proposal has `proposal_kind: "media_buy_cancellation"`, identifies the MediaBuy and base revision, and is applied only through [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal). +Use `change_kind: "cancellation"` when cancellation requires counterparty agreement. The returned draft has `proposal_kind: "media_buy_cancellation"`, identifies the MediaBuy and base revision, and must be finalized before it can be applied through [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal). -Sellers MAY respond asynchronously with `status: "submitted"` when a complex multi-publisher revision requires upstream pricing queries or manual re-underwriting. In that case the response contains a `task_id` for polling via [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json); terminal revised proposals are delivered on the completion artifact or via push notification if `push_notification_config` was supplied. +Sellers MAY respond asynchronously with `status: "submitted"` when a complex multi-publisher revision or inventory hold requires upstream pricing queries or manual re-underwriting. In that case the response contains a `task_id` for polling via [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json); terminal results are delivered on the completion artifact or via push notification if `push_notification_config` was supplied. diff --git a/docs/media-buy/task-reference/request_proposals.mdx b/docs/media-buy/task-reference/request_proposals.mdx index c1062cdd75..00e7d6149e 100644 --- a/docs/media-buy/task-reference/request_proposals.mdx +++ b/docs/media-buy/task-reference/request_proposals.mdx @@ -1,11 +1,11 @@ --- title: request_proposals -description: "Request one or more actionable seller-authored media plans." +description: "Request one or more seller-authored draft media plans." "og:title": "AdCP request_proposals Task" testable: false --- -`request_proposals` creates one or more executable media-plan proposal snapshots from a brief. It can begin a consultative workflow directly or use `product_ids` returned by [`list_products`](/docs/media-buy/task-reference/list_products). Success always contains at least one proposal; returning products without a proposal does not satisfy this task. +`request_proposals` creates one or more immutable draft media-plan proposal snapshots from a brief. It can begin a consultative workflow directly or use `product_ids` returned by [`list_products`](/docs/media-buy/task-reference/list_products). Success always contains at least one proposal; returning products without a proposal does not satisfy this task. `brand` contains only the stable BrandKey (`domain` plus optional `brand_id`); the seller resolves the canonical brand manifest rather than receiving a full brand file in every call. `account` is optional and adds seller-specific commercial terms. A natural-key account (`brand` plus `operator`) can supply the request's sole brand identity instead of repeating top-level `brand`. @@ -28,10 +28,10 @@ testable: false } ``` -Every returned allocation references the exact `product_id` whose pricing and forecast the seller used. The returned `proposal_id` is the only proposal linkage needed by [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals), [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals), and [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal). Each ID identifies one executable immutable commercial snapshot; refinement mints a new ID instead of adding a second version field. The deprecated [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) proposal mode remains the 3.x compatibility adapter. +Every returned allocation references the exact `product_id` whose pricing and forecast the seller used. The returned `proposal_id` is the only proposal linkage needed by [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) and [`decline_proposals`](/docs/media-buy/task-reference/decline_proposals). Each ID identifies one immutable commercial snapshot; refinement and finalization mint new IDs instead of adding a second version field. A draft is indicative and does not reserve inventory. Finalize it through `refine_proposals` before passing the resulting committed proposal to [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal). The deprecated [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) proposal mode remains the 3.x compatibility adapter. `opportunity` is optional shared planning-cycle context and must be open when supplied here. Its buyer-assigned `opportunity_id` can span request, decline, and purchase calls without becoming part of proposal identity. Sellers associate it with every proposal created by the request, and revised proposals inherit the same association. -The response uses `outcome: "proposed"` for a successful executable set and `outcome: "rejected"` with a reason when the seller cannot construct an actionable plan. +The response uses `outcome: "proposed"` for a successful draft set and `outcome: "rejected"` with a reason when the seller cannot construct a viable plan. -Sellers MAY respond asynchronously with `status: "submitted"` when consultative planning requires upstream system queries or human sales-desk review. In that case the response contains a `task_id` for polling via [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json); terminal proposals are delivered on the completion artifact or via push notification if `push_notification_config` was supplied. +Sellers MAY respond asynchronously with `status: "submitted"` when consultative planning requires upstream system queries or human sales-desk review. In that case the response contains a `task_id` for polling via [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json); terminal draft proposals are delivered on the completion artifact or via push notification if `push_notification_config` was supplied. diff --git a/docs/reference/release-notes.mdx b/docs/reference/release-notes.mdx index 01a2ffc892..2070bfce7b 100644 --- a/docs/reference/release-notes.mdx +++ b/docs/reference/release-notes.mdx @@ -18,8 +18,8 @@ Authoritative version-by-version release record for AdCP, with cumulative change AdCP 3.2 adds `list_products`, `request_proposals`, `refine_proposals`, `decline_proposals`, `buy_products`, `accept_proposal`, and `control_media_buy` as compact, task-specific contracts. The broad `get_products`, `create_media_buy`, and `update_media_buy` facades remain functional throughout 3.x but are deprecated for new integrations. - `list_products` is a synchronous, naturally idempotent product-offer read. -- `request_proposals` creates executable proposals with firm terms from a brief and optional listed product IDs. -- `request_proposals` and `refine_proposals` return immutable, executable proposal snapshots; `decline_proposals` records terminal buyer feedback and prevents later execution. +- `request_proposals` creates immutable draft proposals from a brief and optional listed product IDs. +- `refine_proposals` creates draft revisions or atomically finalizes drafts into committed inventory holds; `decline_proposals` records terminal buyer feedback and prevents later execution. - `buy_products` accepts published product offers directly; `accept_proposal` applies new-buy, amendment, or negotiated-cancellation proposals; `control_media_buy` handles revision-checked operational controls inside accepted terms. - Canonical proposals carry typed `commercial_terms` plus an RFC 8785 SHA-256 `terms_digest`. Changed terms mint a new `proposal_id`; accepted MediaBuys expose the current proposal pointer for restart-safe refinement. - New purchase and control tools never accept inline creatives. Creative bodies and assignments use the dedicated creative lifecycle. diff --git a/docs/snippets/compliance-error-codes.mdx b/docs/snippets/compliance-error-codes.mdx index 1e067956be..ca9c8f9ef7 100644 --- a/docs/snippets/compliance-error-codes.mdx +++ b/docs/snippets/compliance-error-codes.mdx @@ -83,10 +83,9 @@ description: "Canonical AdCP error codes with recovery classifications, remediat | `PRODUCT_EXPIRED` | correctable | re-discover with get_products to find current inventory | | `PRODUCT_NOT_FOUND` | correctable | remove invalid IDs and retry, or re-discover with get_products | | `PRODUCT_UNAVAILABLE` | correctable | choose a different product | -| `PROPOSAL_EXPIRED` | correctable | call request_proposals for a fresh executable proposal, or re-discover through legacy get_products | -| `PROPOSAL_NOT_COMMITTED` | correctable | finalize the legacy draft through get_products, or request a fresh executable proposal | -| `PROPOSAL_NOT_FOUND` | correctable | request a fresh executable proposal, then retry | -| `PROPOSAL_UNAVAILABLE` | correctable | call request_proposals for a fresh executable proposal; consider requesting a hold in the brief if inventory reservation is required | +| `PROPOSAL_EXPIRED` | correctable | call request_proposals and finalize a fresh proposal, or re-discover and finalize through legacy get_products | +| `PROPOSAL_NOT_COMMITTED` | correctable | finalize the draft through refine_proposals, or through the legacy get_products refine action | +| `PROPOSAL_NOT_FOUND` | correctable | request and finalize a fresh proposal, then retry | | `PROVENANCE_CLAIM_CONTRADICTED` | correctable | revise the provenance claim to match the verifier's observation or replace the creative; auto-retry without correction will not pass | | `PROVENANCE_DIGITAL_SOURCE_TYPE_MISSING` | correctable | set provenance.digital_source_type to a value from the digital-source-type enum and resubmit | | `PROVENANCE_DISCLOSURE_MISSING` | correctable | set provenance.disclosure.required and, when true, populate disclosure.jurisdictions | @@ -821,36 +820,27 @@ The requested product is sold out or no longer available. Recovery: correctable -**Suggested action:** call request_proposals for a fresh executable proposal, or re-discover through legacy get_products +**Suggested action:** call request_proposals and finalize a fresh proposal, or re-discover and finalize through legacy get_products -A referenced proposal ID has passed its expires_at timestamp. Recovery: correctable (call request_proposals to get a fresh executable proposal, or re-discover through the legacy get_products facade). +A referenced proposal ID has passed its expires_at timestamp. For a committed proposal, the inventory hold has lapsed. Recovery: correctable (call request_proposals and finalize a fresh proposal, or re-discover and finalize through the legacy get_products facade). -**Suggested action:** finalize the legacy draft through get_products, or request a fresh executable proposal +**Suggested action:** finalize the draft through refine_proposals, or through the legacy get_products refine action -The referenced legacy get_products proposal has proposal_status 'draft' and cannot be used to create a media buy. Recovery: correctable (finalize it through get_products first). AdCP 3.2 request_proposals and refine_proposals return executable committed snapshots. +The referenced proposal has proposal_status 'draft' and cannot be accepted into a media buy. Recovery: correctable (finalize it through refine_proposals, or through the legacy get_products refine action). -**Suggested action:** request a fresh executable proposal, then retry +**Suggested action:** request and finalize a fresh proposal, then retry -The referenced proposal_id is not recognized by the seller — it belongs to a different tenant, was never issued, or was evicted from the seller's session cache before consumption. Distinct from {"PROPOSAL_EXPIRED"} (a known proposal whose {"expires_at"} window has passed) and {"PROPOSAL_NOT_COMMITTED"} (a known legacy get_products proposal still in {"draft"}). Recovery: correctable (request a fresh executable proposal, then retry accept_proposal; 3.x compatibility callers retry create_media_buy). - - - - - - -**Suggested action:** call request_proposals for a fresh executable proposal; consider requesting a hold in the brief if inventory reservation is required - -An unexpired committed proposal cannot be accepted because inventory is no longer available and the seller did not reserve it at proposal time. The proposal was valid when issued; the seller has not reserved the inventory and the available slots have since sold. Distinct from {"PROPOSAL_EXPIRED"} (expires_at window passed) and {"PROPOSAL_NOT_FOUND"} (unknown proposal_id). Recovery: correctable (call request_proposals to obtain a fresh proposal; consider asking the seller about hold options in the request brief). +The referenced proposal_id is not recognized by the seller — it belongs to a different tenant, was never issued, or was evicted from the seller's session cache before consumption. Distinct from {"PROPOSAL_EXPIRED"} (a known proposal whose {"expires_at"} window has passed) and {"PROPOSAL_NOT_COMMITTED"} (a known proposal still in {"draft"}). Recovery: correctable (request a fresh proposal, finalize it, then retry accept_proposal; 3.x compatibility callers retry create_media_buy). diff --git a/scripts/error-code-drift-dispositions.json b/scripts/error-code-drift-dispositions.json index 9d3df351a0..285757361d 100644 --- a/scripts/error-code-drift-dispositions.json +++ b/scripts/error-code-drift-dispositions.json @@ -156,11 +156,6 @@ "target_version": "3.1", "note": "Proposal-lifecycle counterpart to PROPOSAL_EXPIRED / PROPOSAL_NOT_COMMITTED (#4043). Returned when a referenced proposal_id is unknown to the seller (never finalized, wrong tenant, or evicted from cache). Buyer-fixable: request and finalize a current proposal. Wire change \u2014 held for 3.1." }, - "PROPOSAL_UNAVAILABLE": { - "disposition": "held-for-next-minor", - "target_version": "3.2", - "note": "Compact proposal-lifecycle acceptance signal. Returned when an unexpired committed proposal cannot be accepted because the seller did not reserve inventory and availability changed before acceptance. Wire change \u2014 held for 3.2." - }, "PROVENANCE_CLAIM_CONTRADICTED": { "disposition": "held-for-next-minor", "target_version": "3.1", diff --git a/server/src/training-agent/source-schema.ts b/server/src/training-agent/source-schema.ts index 8fe36bae78..18dd70d312 100644 --- a/server/src/training-agent/source-schema.ts +++ b/server/src/training-agent/source-schema.ts @@ -220,3 +220,20 @@ export function validateProductDiscoverySourceInput( ...(field && { field }), }; } + +/** Validate a split-tool response against its normative source schema. This + * is primarily used by the training-agent contract tests so a compatibility + * handler cannot accidentally leak legacy shapes onto the compact wire. */ +export function validateProductDiscoverySourceResponse( + fileName: string, + response: Record, +): { message: string; field?: string } | undefined { + const validator = productDiscoverySourceValidator(fileName); + if (validator(response)) return undefined; + const error = validator.errors?.[0]; + const field = error && errorField(error); + return { + message: `Invalid ${fileName.replaceAll('-', '_')}${field ? ` at ${field}` : ''}: ${error?.message ?? 'schema validation failed'}`, + ...(field && { field }), + }; +} diff --git a/server/src/training-agent/state.ts b/server/src/training-agent/state.ts index b71f5012b8..743916d8ca 100644 --- a/server/src/training-agent/state.ts +++ b/server/src/training-agent/state.ts @@ -269,6 +269,7 @@ function createSession(): SessionState { contentStandards: new Map(), rightsGrants: new Map(), negotiatedPricingOptions: new Map(), + proposalLifecycleLinks: new Map(), creatives: new Map(), signalActivations: new Map(), buildVariantTargets: new Map(), @@ -494,6 +495,7 @@ function deserializeSession(data: Record): SessionState { contentStandards: asMap(hydrated.contentStandards, fresh.contentStandards), rightsGrants: asMap(hydrated.rightsGrants, fresh.rightsGrants), negotiatedPricingOptions: asMap(hydrated.negotiatedPricingOptions, fresh.negotiatedPricingOptions), + proposalLifecycleLinks: asMap(hydrated.proposalLifecycleLinks, fresh.proposalLifecycleLinks), buildVariantTargets: asMap(hydrated.buildVariantTargets, fresh.buildVariantTargets), usageRecords: Array.isArray(hydrated.usageRecords) ? hydrated.usageRecords : [], complyExtensions: { diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 62302dca35..8a4f75e6cb 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -16,7 +16,7 @@ import { McpError, } from '@modelcontextprotocol/sdk/types.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { PostgresTaskStore } from '@adcp/sdk'; +import { canonicalize, PostgresTaskStore } from '@adcp/sdk'; import { canonicalFormatLegacyResolverFromCatalogSnapshots, canonicalFormatLegacyResolverFromRoutes, @@ -1412,16 +1412,16 @@ function signalPricingSatisfiedInCurrencies(signalOption: { pricing_options?: un // Proposal lifecycle fields not yet in @adcp/sdk — remove after client update interface ProposalLifecycle { - proposal_status?: 'draft' | 'committed'; + proposal_status?: 'draft' | 'committed' | 'accepted'; insertion_order?: { io_id: string; requires_signature: boolean; terms?: Record }; } function proposalLifecycle(proposal: Proposal): ProposalLifecycle { + const internal = proposal as unknown as Record; + if (internal.__executed === true) return { ...proposal as unknown as ProposalLifecycle, proposal_status: 'accepted' }; return proposal as unknown as ProposalLifecycle; } -/** Return an exact proposal snapshot that can be accepted directly through - * create_media_buy. The compact 3.2 lifecycle has no separate finalize step; - * legacy get_products may still use this helper for its finalize refinement. */ +/** Return an exact proposal snapshot backed by a 24-hour inventory hold. */ function executableProposalSnapshot(proposal: Proposal, brandDomain?: string): Proposal { const executable = { ...proposal } as Record & ProposalLifecycle; executable.proposal_status = 'committed'; @@ -1459,6 +1459,16 @@ function executableProposalSnapshot(proposal: Proposal, brandDomain?: string): P return executable as unknown as Proposal; } +/** Return an immutable indicative proposal. Its expiry is a terms-freshness + * deadline, not an inventory hold; only finalize creates a committed hold. */ +function draftProposalSnapshot(proposal: Proposal): Proposal { + const draft = { ...proposal } as Record & ProposalLifecycle; + draft.proposal_status = 'draft'; + draft.expires_at = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + delete draft.insertion_order; + return draft as unknown as Proposal; +} + type ConcreteCpmAsk = { currency?: string; budget?: { amount: number; currency: string }; @@ -4303,8 +4313,13 @@ export function normalizeProductDiscoveryArgs( return { scope: 'proposal', proposal_id: entry.proposal_id, - action: 'include', - ...(typeof entry.instructions === 'string' && { ask: entry.instructions }), + action: entry.action === 'finalize' ? 'finalize' : 'include', + ...(entry.action !== 'finalize' + && typeof entry.instructions === 'string' + && { ask: entry.instructions }), + ...(entry.action !== 'finalize' + && (entry.change_kind === 'amendment' || entry.change_kind === 'cancellation') + && { change_kind: entry.change_kind }), }; }) : [], @@ -4340,8 +4355,11 @@ function supportingProductsForProposals( ): Array> { const referenced = new Set(); for (const proposal of proposals) { - if (!Array.isArray(proposal.allocations)) continue; - for (const allocation of proposal.allocations) { + const commercialTerms = isRecord(proposal.commercial_terms) ? proposal.commercial_terms : undefined; + const selections = Array.isArray(commercialTerms?.purchases) + ? commercialTerms.purchases + : Array.isArray(proposal.allocations) ? proposal.allocations : []; + for (const allocation of selections) { if (isRecord(allocation) && typeof allocation.product_id === 'string') { referenced.add(allocation.product_id); } @@ -4350,23 +4368,139 @@ function supportingProductsForProposals( return products.filter(product => typeof product.product_id === 'string' && referenced.has(product.product_id)); } -function outwardProposal(proposal: Record): Record { - const { - __source_proposal_id: _sourceProposalId, - __brand_domain: _brandDomain, - __brand_id: _brandId, - __account_id: _accountId, - __refinement_outcome: _refinementOutcome, - __refinement_notes: _refinementNotes, - __opportunity_id: _opportunityId, - __declined: _declined, - __decline_reason: _declineReason, - __decline_detail: _declineDetail, - __executed: _executed, - __opportunity_update: _opportunityUpdate, - ...outward - } = proposal; - return outward; +const CANONICAL_PRICING_FIELDS = [ + 'pricing_option_id', 'pricing_model', 'currency', 'price_guidance', + 'min_spend_per_package', 'price_breakdown', 'eligible_adjustments', + 'parameters', 'event_type', 'custom_event_name', 'event_source_id', + 'commission_rate', 'commission_basis_description', +] as const; + +function canonicalPricingSnapshot(raw: unknown, pricingOptionId: string): Record { + const source = isRecord(raw) ? raw : {}; + const snapshot: Record = {}; + for (const field of CANONICAL_PRICING_FIELDS) { + if (source[field] !== undefined) snapshot[field] = structuredClone(source[field]); + } + snapshot.pricing_option_id = pricingOptionId; + if (typeof snapshot.pricing_model !== 'string') snapshot.pricing_model = 'cpm'; + if (typeof snapshot.currency !== 'string') snapshot.currency = 'USD'; + // A selected fixed price supersedes an auction floor in the canonical + // snapshot; the schema intentionally forbids carrying both. + if (typeof source.fixed_price === 'number') snapshot.fixed_price = source.fixed_price; + else if (typeof source.floor_price === 'number') snapshot.floor_price = source.floor_price; + return snapshot; +} + +function proposalTermsDigest(commercialTerms: Record): string { + return `sha256:${createHash('sha256').update(canonicalize(commercialTerms), 'utf8').digest('base64url')}`; +} + +function buildCanonicalCommercialTerms( + proposal: Proposal, + products: Map, + brand: { domain: string; brand_id?: string }, +): Record { + const internal = proposal as unknown as Record; + if (isRecord(internal.__canonical_commercial_terms)) { + return structuredClone(internal.__canonical_commercial_terms); + } + const startTime = typeof internal.__commercial_start_time === 'string' + ? internal.__commercial_start_time + : new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + const endTime = typeof internal.__commercial_end_time === 'string' + ? internal.__commercial_end_time + : new Date(Date.now() + 31 * 24 * 60 * 60 * 1000).toISOString(); + const recommendedBudget = proposal.total_budget_guidance?.recommended; + const currency = proposal.total_budget_guidance?.currency ?? 'USD'; + const purchases = proposal.allocations.map(allocation => { + const product = products.get(allocation.product_id) + ?? getCatalog().find(entry => entry.product.product_id === allocation.product_id)?.product; + const pricingOptionId = allocation.pricing_option_id + ?? product?.pricing_options?.[0]?.pricing_option_id + ?? `${allocation.product_id}_pricing`; + const pricing = product?.pricing_options?.find(option => option.pricing_option_id === pricingOptionId) + ?? product?.pricing_options?.[0]; + return { + product_id: allocation.product_id, + pricing_option_id: pricingOptionId, + pricing: canonicalPricingSnapshot(pricing, pricingOptionId), + start_time: startTime, + end_time: endTime, + ...(typeof recommendedBudget === 'number' && { + budget: recommendedBudget * allocation.allocation_percentage / 100, + }), + ...(isRecord((product as unknown as Record | undefined)?.measurement_terms) + && { measurement_terms: structuredClone((product as unknown as Record).measurement_terms) }), + ...(Array.isArray((product as unknown as Record | undefined)?.performance_standards) + && { performance_standards: structuredClone((product as unknown as Record).performance_standards) }), + }; + }); + return { + brand, + purchases, + start_time: startTime, + end_time: endTime, + ...(typeof recommendedBudget === 'number' && { + total_budget: { amount: recommendedBudget, currency }, + }), + }; +} + +function withCanonicalProposalEnvelope( + proposal: Proposal, + products: Map, + brand: { domain: string; brand_id?: string }, + options: { rebuild?: boolean } = {}, +): Proposal { + const internal = { ...proposal } as unknown as Record; + if (options.rebuild) { + delete internal.__canonical_commercial_terms; + delete internal.__canonical_terms_digest; + } + internal.__commercial_start_time ??= new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + internal.__commercial_end_time ??= new Date(Date.now() + 31 * 24 * 60 * 60 * 1000).toISOString(); + internal.__proposal_kind ??= 'new_media_buy'; + const commercialTerms = buildCanonicalCommercialTerms(internal as unknown as Proposal, products, brand); + internal.__canonical_commercial_terms = commercialTerms; + internal.__canonical_terms_digest = proposalTermsDigest(commercialTerms); + return internal as unknown as Proposal; +} + +function compactCanonicalProduct(product: Record): Record { + return { + product_id: product.product_id, + name: product.name, + }; +} + +function outwardProposal(proposal: Record, products: Map): Record { + const brand = { + domain: typeof proposal.__brand_domain === 'string' ? proposal.__brand_domain : 'advertiser.example', + ...(typeof proposal.__brand_id === 'string' && { brand_id: proposal.__brand_id }), + }; + const terms = isRecord(proposal.__canonical_commercial_terms) + ? structuredClone(proposal.__canonical_commercial_terms) + : buildCanonicalCommercialTerms(proposal as unknown as Proposal, products, brand); + const status = proposalLifecycle(proposal as unknown as Proposal).proposal_status ?? 'draft'; + return { + proposal_id: proposal.proposal_id, + proposal_kind: typeof proposal.__proposal_kind === 'string' ? proposal.__proposal_kind : 'new_media_buy', + ...(typeof proposal.__parent_proposal_id === 'string' && { parent_proposal_id: proposal.__parent_proposal_id }), + ...(typeof proposal.__media_buy_id === 'string' && { media_buy_id: proposal.__media_buy_id }), + ...(typeof proposal.__base_media_buy_revision === 'number' && { base_media_buy_revision: proposal.__base_media_buy_revision }), + ...(typeof proposal.__opportunity_id === 'string' && { opportunity_id: proposal.__opportunity_id }), + proposal_status: status, + ...(status === 'accepted' && typeof proposal.__accepted_at === 'string' && { accepted_at: proposal.__accepted_at }), + ...(typeof proposal.expires_at === 'string' && { expires_at: proposal.expires_at }), + name: proposal.name, + ...(typeof proposal.description === 'string' && { description: proposal.description }), + ...(typeof proposal.brief_alignment === 'string' && { brief_alignment: proposal.brief_alignment }), + commercial_terms: terms, + terms_digest: typeof proposal.__canonical_terms_digest === 'string' + ? proposal.__canonical_terms_digest + : proposalTermsDigest(terms), + ...(isRecord(proposal.insertion_order) && { insertion_order: proposal.insertion_order }), + }; } /** Project the broad 3.x handler result into the compact split-tool domain @@ -4390,6 +4524,9 @@ export function projectProductDiscoveryResult( let products = Array.isArray(result.products) ? result.products.filter(isRecord) : []; + const proposalProducts = new Map(products + .filter((product): product is Record & { product_id: string } => typeof product.product_id === 'string') + .map(product => [product.product_id, product as unknown as Product])); let proposals = Array.isArray(result.proposals) ? result.proposals.filter(isRecord) : []; @@ -4435,32 +4572,39 @@ export function projectProductDiscoveryResult( } if (toolName === 'request_proposals') { - const outwardProposals = proposals.map(outwardProposal); + const outwardProposals = proposals.map(proposal => outwardProposal(proposal, proposalProducts)); + const supportingProducts = supportingProductsForProposals(outwardProposals, products).map(compactCanonicalProduct); return { outcome: 'proposed', proposals: outwardProposals, - products: supportingProductsForProposals(outwardProposals, products), + products: supportingProducts, ...(isRecord(result.targeting_resolution) && { targeting_resolution: result.targeting_resolution }), }; } if (toolName === 'refine_proposals') { - const sourceIds = Array.isArray(originalArgs.refinements) - ? originalArgs.refinements - .filter(isRecord) - .map(entry => entry.proposal_id) - .filter((id): id is string => typeof id === 'string') + const requestedRefinements = Array.isArray(originalArgs.refinements) + ? originalArgs.refinements.filter(isRecord) : []; + const sourceIds = requestedRefinements + .map(entry => entry.proposal_id) + .filter((id): id is string => typeof id === 'string'); + const actionBySource = new Map(requestedRefinements + .filter((entry): entry is Record & { proposal_id: string } => typeof entry.proposal_id === 'string') + .map(entry => [entry.proposal_id, entry.action === 'finalize' ? 'finalize' : 'revise'] as const)); const proposalsBySource = new Map(proposals.map(proposal => [ proposal.__source_proposal_id, proposal, ])); - const outwardProposals = Array.from(proposalsBySource.values()).map(outwardProposal); + const outwardProposals = Array.from(proposalsBySource.values()) + .map(proposal => outwardProposal(proposal, proposalProducts)); return { results: sourceIds.map(sourceProposalId => { const internalProposal = proposalsBySource.get(sourceProposalId); - const proposal = internalProposal && outwardProposal(internalProposal); - const outcome = internalProposal?.__refinement_outcome === 'partial' ? 'partial' : 'revised'; + const proposal = internalProposal && outwardProposal(internalProposal, proposalProducts); + const outcome = actionBySource.get(sourceProposalId) === 'finalize' + ? 'finalized' + : internalProposal?.__refinement_outcome === 'partial' ? 'partial' : 'revised'; return proposal ? { source_proposal_id: sourceProposalId, @@ -4475,7 +4619,7 @@ export function projectProductDiscoveryResult( reason: 'The source proposal was not found or could not be revised under the requested terms.', }; }), - products: supportingProductsForProposals(outwardProposals, products), + products: supportingProductsForProposals(outwardProposals, products).map(compactCanonicalProduct), }; } if (toolName === 'decline_proposals') { @@ -4625,7 +4769,21 @@ export function validateProductDiscoveryAliasInput( } proposalIds.add(entry.proposal_id); if (!(typeof entry.instructions === 'string' && entry.instructions.length > 0)) { - return { message: 'each refinement requires instructions', field: `refinements[${index}].instructions` }; + if (entry.action !== 'finalize') { + return { message: 'each revision requires instructions', field: `refinements[${index}].instructions` }; + } + } + if (entry.action !== undefined && entry.action !== 'revise' && entry.action !== 'finalize') { + return { + message: 'action must be revise or finalize', + field: `refinements[${index}].action`, + }; + } + if (entry.action === 'finalize' && (entry.instructions !== undefined || entry.change_kind !== undefined)) { + return { + message: 'finalize cannot be combined with instructions or change_kind', + field: `refinements[${index}].action`, + }; } if ( entry.change_kind !== undefined @@ -4638,12 +4796,19 @@ export function validateProductDiscoveryAliasInput( }; } const unknown = Object.keys(entry).find( - field => !['proposal_id', 'change_kind', 'instructions'].includes(field), + field => !['proposal_id', 'action', 'change_kind', 'instructions'].includes(field), ); if (unknown) { return { message: `${unknown} is not supported on proposal refinements`, field: `refinements[${index}].${unknown}` }; } } + const hasFinalize = args.refinements.some(entry => isRecord(entry) && entry.action === 'finalize'); + if (hasFinalize && args.refinements.some(entry => !isRecord(entry) || entry.action !== 'finalize')) { + return { + message: 'finalize entries cannot be mixed with proposal revisions', + field: 'refinements', + }; + } } if (toolName === 'decline_proposals') { if (!Array.isArray(args.declines) || args.declines.length === 0) { @@ -4764,14 +4929,14 @@ const TOOLS = [ }, { name: 'request_proposals', - description: 'Request executable media-plan proposals from a brief and optional listed product IDs. Each proposal_id is an exact snapshot that can be refined, purchased, or declined.', + description: 'Request immutable draft media-plan proposals from a brief and optional listed product IDs. Drafts can be revised, finalized into inventory holds, or declined.', annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, execution: { taskSupport: 'optional' as const }, inputSchema: REQUEST_PROPOSALS_INPUT_SCHEMA, }, { name: 'refine_proposals', - description: 'Create executable revisions from one or more proposals. Changed terms receive a new proposal_id; source snapshots remain immutable.', + description: 'Create draft revisions or finalize drafts into committed inventory holds. Every result receives a new proposal_id; source snapshots remain immutable.', annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, execution: { taskSupport: 'optional' as const }, inputSchema: REFINE_PROPOSALS_INPUT_SCHEMA, @@ -5588,6 +5753,7 @@ async function handleGetProductsUnlocked( proposal_id: string; action?: 'include' | 'omit' | 'finalize' | 'decline'; ask?: string; + change_kind?: 'amendment' | 'cancellation'; reason?: string; detail?: string; }; @@ -5603,6 +5769,13 @@ async function handleGetProductsUnlocked( && (req as unknown as Record).__compact_proposal_lifecycle === true; const declineProposals = buyingMode === 'refine' && (req as unknown as Record).__decline_proposals === true; + const compactFinalizeSourceIds = new Set( + immutableRefine && Array.isArray(req.refine) + ? (req.refine as unknown as RefineEntry[]) + .filter(entry => entry.scope === 'proposal' && entry.action === 'finalize') + .map(entry => (entry as Extract).proposal_id) + : [], + ); const refinementApplied: RefinementAppliedEntry[] = []; const proposalOmitIds = new Set(); const refinedProposalOverrides = new Map(); @@ -5663,6 +5836,16 @@ async function handleGetProductsUnlocked( const proposal = resolveProposal(op.proposal_id); if (!proposal) { if (declineProposals) everyDeclineApplicable = false; + if (immutableRefine && op.action === 'finalize') { + return { + errors: [{ + code: 'PROPOSAL_NOT_FOUND', + message: `Proposal not found: ${op.proposal_id}`, + field: `refine[${opIndex}].proposal_id`, + recovery: 'correctable', + }] as TaskError[], + }; + } if (immutableRefine || declineProposals) continue; return { errors: [{ @@ -5691,11 +5874,43 @@ async function handleGetProductsUnlocked( } } if (op.action === 'finalize') { - if ((proposal as unknown as Record).__declined === true) { + const internal = proposal as unknown as Record; + const lifecycleLink = session.proposalLifecycleLinks.get(op.proposal_id); + if (lifecycleLink) { + if ( + lifecycleLink.operation === 'finalize' + && lifecycleLink.idempotencyKey === req.idempotency_key + && previousProposals.some(candidate => candidate.proposal_id === lifecycleLink.successorProposalId) + ) { + continue; + } + return { + errors: [{ + code: 'INVALID_STATE', + message: `Proposal was already finalized: ${op.proposal_id}`, + field: `refine[${opIndex}].proposal_id`, + recovery: 'correctable', + }] as TaskError[], + }; + } + if ( + internal.__declined === true + || internal.__executed === true + ) { return { errors: [{ code: 'INVALID_STATE', - message: `Proposal has been declined and cannot be finalized: ${op.proposal_id}`, + message: `Proposal is terminal and cannot be finalized: ${op.proposal_id}`, + field: `refine[${opIndex}].proposal_id`, + recovery: 'correctable', + }] as TaskError[], + }; + } + if (immutableRefine && proposalLifecycle(proposal).proposal_status !== 'draft') { + return { + errors: [{ + code: 'INVALID_STATE', + message: `Only a draft proposal can be finalized: ${op.proposal_id}`, field: `refine[${opIndex}].proposal_id`, recovery: 'correctable', }] as TaskError[], @@ -5815,15 +6030,9 @@ async function handleGetProductsUnlocked( }); continue; } - if (immutableRefine && (proposal as unknown as Record).__executed === true) { - refinementApplied.push({ - scope: 'proposal', - proposal_id: op.proposal_id, - status: 'unable', - notes: 'Proposal was already executed and cannot be refined', - }); - continue; - } + // An executed compact proposal is the accepted immutable snapshot. + // Refining it forks an amendment/cancellation draft; the accepted + // source remains the historical terms attached to the MediaBuy. explicitlySelectedProposals.set(proposal.proposal_id, proposal); for (const allocation of proposal.allocations) includeIds.add(allocation.product_id); if (!immutableRefine && proposalLifecycle(proposal).proposal_status === 'committed' && op.ask) { @@ -5887,7 +6096,16 @@ async function handleGetProductsUnlocked( } } else if (action === 'finalize') { const status = proposalLifecycle(proposal).proposal_status; - if (status === 'committed') { + if (immutableRefine) { + explicitlySelectedProposals.set(proposal.proposal_id, proposal); + for (const allocation of proposal.allocations) includeIds.add(allocation.product_id); + refinementApplied.push({ + scope: 'proposal', + proposal_id: op.proposal_id, + status: 'applied', + notes: 'Proposal finalized — pricing committed and inventory held for 24 hours', + }); + } else if (status === 'committed') { refinementApplied.push({ scope: 'proposal', proposal_id: op.proposal_id, status: 'applied', notes: 'Proposal already committed' }); } else { const accountBrand = (req as unknown as Record).account as Record | undefined; @@ -6042,7 +6260,10 @@ async function handleGetProductsUnlocked( .map(proposal => { // A committed proposal is a receipt for a specific inventory hold. // Later catalog/pricing discovery must not rewrite any part of it. - if (proposalLifecycle(proposal).proposal_status === 'committed') return proposal; + if ( + proposalLifecycle(proposal).proposal_status === 'committed' + || compactFinalizeSourceIds.has(proposal.proposal_id) + ) return proposal; return { ...proposal, allocations: proposal.allocations.map(alloc => { @@ -6101,9 +6322,15 @@ async function handleGetProductsUnlocked( && { __opportunity_id: requestOpportunity.opportunity_id }), } as unknown as Proposal; return existingById.get(proposalId) - ?? executableProposalSnapshot( - snapshot, - typeof requestBrand?.domain === 'string' ? requestBrand.domain.toLowerCase() : undefined, + ?? withCanonicalProposalEnvelope( + draftProposalSnapshot(snapshot), + productsById, + { + domain: typeof requestBrand?.domain === 'string' + ? requestBrand.domain.toLowerCase() + : 'advertiser.example', + ...(typeof requestBrand?.brand_id === 'string' && { brand_id: requestBrand.brand_id }), + }, ); }); if (proposals.length === 0) { @@ -6129,23 +6356,76 @@ async function handleGetProductsUnlocked( .filter((entry): entry is Extract => entry.scope === 'proposal') .map(entry => [entry.proposal_id, entry]), ); + const actionBySource = new Map( + (req.refine as unknown as RefineEntry[]) + .filter((entry): entry is Extract => entry.scope === 'proposal') + .map(entry => [entry.proposal_id, { + action: entry.action === 'finalize' ? 'finalize' as const : 'revise' as const, + changeKind: entry.change_kind, + instructions: entry.ask, + }]), + ); proposals = sourceProposalOrder.flatMap((sourceId, index) => { const proposal = proposalsById.get(sourceId); const outcome = outcomesBySource.get(sourceId); if (!proposal || outcome?.status === 'unable') return []; const digest = createHash('sha256').update(`${key}:${sourceId}:${index}`).digest('hex').slice(0, 24); + const sourceInternal = proposal as unknown as Record; + const refinement = actionBySource.get(sourceId); + const isAcceptedSource = sourceInternal.__executed === true; const revision = { ...proposal, proposal_id: `proposal_revision_${digest}`, __source_proposal_id: sourceId, __refinement_outcome: outcome?.status === 'partial' ? 'partial' : 'revised', ...(outcome?.notes && { __refinement_notes: outcome.notes }), + ...(isAcceptedSource && { + __proposal_kind: refinement?.changeKind === 'cancellation' + ? 'media_buy_cancellation' + : 'media_buy_update', + __parent_proposal_id: sourceId, + __media_buy_id: sourceInternal.__media_buy_id, + __base_media_buy_revision: sourceInternal.__media_buy_revision, + }), } as unknown as Proposal; + if (isAcceptedSource) { + const revisionInternal = revision as unknown as Record; + delete revisionInternal.__executed; + delete revisionInternal.__accepted_at; + delete revisionInternal.__opportunity_update; + } + const existingSuccessor = session.lastGetProductsContext?.proposals?.find( + candidate => candidate.proposal_id === revision.proposal_id, + ); + if (existingSuccessor) return [existingSuccessor]; const brandDomain = (proposal as unknown as Record).__brand_domain; - return [executableProposalSnapshot( - revision, - typeof brandDomain === 'string' ? brandDomain : undefined, - )]; + const brandId = (proposal as unknown as Record).__brand_id; + let successor = refinement?.action === 'finalize' + ? executableProposalSnapshot( + revision, + typeof brandDomain === 'string' ? brandDomain : undefined, + ) + : withCanonicalProposalEnvelope( + draftProposalSnapshot(revision), + productsById, + { + domain: typeof brandDomain === 'string' ? brandDomain : 'advertiser.example', + ...(typeof brandId === 'string' && { brand_id: brandId }), + }, + { rebuild: true }, + ); + if (refinement?.changeKind === 'cancellation') { + const successorInternal = successor as unknown as Record; + const commercialTerms = structuredClone(successorInternal.__canonical_commercial_terms) as Record; + commercialTerms.cancellation_terms = { + effective_at: new Date().toISOString(), + ...(typeof refinement.instructions === 'string' && { reason: refinement.instructions.slice(0, 500) }), + }; + successorInternal.__canonical_commercial_terms = commercialTerms; + successorInternal.__canonical_terms_digest = proposalTermsDigest(commercialTerms); + successor = successorInternal as unknown as Proposal; + } + return [successor]; }); } const canonicalFormatAdvisories = collectCanonicalFormatAdvisories(products); @@ -6167,11 +6447,33 @@ async function handleGetProductsUnlocked( .filter(proposal => proposalLifecycle(proposal).proposal_status === 'committed') .map(proposal => [proposal.proposal_id, proposal]), ); + const finalizedBySource = new Map( + proposals + .map(proposal => proposal as unknown as Record) + .filter(proposal => ( + typeof proposal.__source_proposal_id === 'string' + && proposal.proposal_status === 'committed' + && typeof proposal.proposal_id === 'string' + )) + .map(proposal => [proposal.__source_proposal_id as string, proposal.proposal_id as string]), + ); const priorProposals = session.lastGetProductsContext?.proposals ?? []; + const refinementIdempotencyKey = typeof (req as unknown as Record).idempotency_key === 'string' + ? (req as unknown as Record).idempotency_key as string + : undefined; + if (refinementIdempotencyKey) { + for (const [sourceProposalId, successorProposalId] of finalizedBySource) { + session.proposalLifecycleLinks.set(sourceProposalId, { + operation: 'finalize', + idempotencyKey: refinementIdempotencyKey, + successorProposalId, + }); + } + } const persistedProposals = buyingMode === 'wholesale' ? [] : immutableRefine - ? [...priorProposals, ...proposals] + ? [...new Map([...priorProposals, ...proposals].map(proposal => [proposal.proposal_id, proposal])).values()] : requireProposals ? [ ...priorProposals.filter(prior => !proposals.some(proposal => proposal.proposal_id === prior.proposal_id)), @@ -7662,14 +7964,14 @@ async function handleCreateMediaBuyUnlocked(args: ToolArgs, ctx: TrainingContext const proposalStatus = proposalLifecycle(proposal).proposal_status; if (proposalStatus === 'draft' && !(isThreeZeroStoryboardCompat(ctx) && req.proposal_id === THREE_ZERO_LEGACY_PROPOSAL_ID)) { return { - errors: [{ code: 'PROPOSAL_NOT_COMMITTED', message: `Proposal "${req.proposal_id}" has legacy draft status — finalize it through get_products before retrying.` }] as TaskError[], + errors: [{ code: 'PROPOSAL_NOT_COMMITTED', message: `Proposal "${req.proposal_id}" is a draft — finalize it through refine_proposals before retrying.` }] as TaskError[], }; } // Enforce proposal expiry if (proposal.expires_at && new Date(proposal.expires_at) < new Date()) { return { - errors: [{ code: 'PROPOSAL_EXPIRED', message: `Proposal "${req.proposal_id}" expired at ${proposal.expires_at}. Request a fresh proposal before retrying.` }] as TaskError[], + errors: [{ code: 'PROPOSAL_EXPIRED', message: `Proposal "${req.proposal_id}" expired at ${proposal.expires_at}. Request and finalize a fresh proposal before retrying.` }] as TaskError[], }; } @@ -8055,6 +8357,9 @@ async function handleCreateMediaBuyUnlocked(args: ToolArgs, ctx: TrainingContext if (executedCompactProposal) { const internal = executedCompactProposal as unknown as Record; internal.__executed = true; + internal.__accepted_at = now; + internal.__media_buy_id = mediaBuyId; + internal.__media_buy_revision = mediaBuy.revision; const suppliedOpportunity = isRecord((req as unknown as Record).opportunity) ? (req as unknown as Record).opportunity as Record : undefined; diff --git a/server/src/training-agent/tenants/tenant-smoke.test.ts b/server/src/training-agent/tenants/tenant-smoke.test.ts index dca6e7259e..d8b40b7bf5 100644 --- a/server/src/training-agent/tenants/tenant-smoke.test.ts +++ b/server/src/training-agent/tenants/tenant-smoke.test.ts @@ -1561,7 +1561,12 @@ describe('tenant routing smoke', () => { items: { $ref: '#/$defs/media-buy~1proposal-refinement.json' }, }); expect(refineAlias?.inputSchema?.$defs?.['media-buy/proposal-refinement.json']) - .toMatchObject({ type: 'object', required: ['proposal_id', 'instructions'] }); + .toMatchObject({ + type: 'object', + required: ['proposal_id'], + properties: { action: { enum: ['revise', 'finalize'] } }, + oneOf: expect.any(Array), + }); const keylessLegacy = await callTenantTool(url, 3, 'get_products', { buying_mode: 'wholesale', diff --git a/server/src/training-agent/types.ts b/server/src/training-agent/types.ts index 4b88da27e0..c57ee833fb 100644 --- a/server/src/training-agent/types.ts +++ b/server/src/training-agent/types.ts @@ -355,6 +355,15 @@ export interface SessionState { productId: string; option: Product['pricing_options'][number]; }>; + /** Durable proposal-successor receipts kept outside immutable proposal + * snapshots. Finalization uses this to recover an exact idempotent retry + * after domain state was flushed but before the idempotency receipt was + * published. */ + proposalLifecycleLinks: Map; usageRecords: UsageRecord[]; /** Maps build_variant_id → the FormatID target used to produce it. * Populated when build_creative returns a build_variant_id so that a diff --git a/server/tests/unit/product-discovery-schema-parity.test.ts b/server/tests/unit/product-discovery-schema-parity.test.ts index ace630e497..df9b050c12 100644 --- a/server/tests/unit/product-discovery-schema-parity.test.ts +++ b/server/tests/unit/product-discovery-schema-parity.test.ts @@ -80,7 +80,14 @@ describe('product discovery MCP schema parity', () => { }); expect(resolveLocalRef(list, list.properties.criteria)).toMatchObject({ additionalProperties: false }); expect(resolveLocalRef(refineTool, refineTool.properties.refinements.items)) - .toMatchObject({ required: ['proposal_id', 'instructions'], additionalProperties: false }); + .toMatchObject({ + required: ['proposal_id'], + additionalProperties: false, + // Definition annotations are intentionally stripped from tools/list; + // dispatch still applies the source-schema default semantics. + properties: { action: { enum: ['revise', 'finalize'] } }, + oneOf: expect.any(Array), + }); const declineTool = tools.get('decline_proposals')!; expect(resolveLocalRef(declineTool, declineTool.properties.declines.items)) .toMatchObject({ required: ['proposal_id', 'reason'], additionalProperties: false }); diff --git a/server/tests/unit/training-agent-idempotency.test.ts b/server/tests/unit/training-agent-idempotency.test.ts index cd9801b36a..18c3fa7c9d 100644 --- a/server/tests/unit/training-agent-idempotency.test.ts +++ b/server/tests/unit/training-agent-idempotency.test.ts @@ -625,6 +625,66 @@ describe('training agent idempotency middleware', () => { expect((conflict.parsed as any).adcp_error?.code).toBe('IDEMPOTENCY_CONFLICT'); }); + it('replays compact finalization without allocating a second inventory hold', async () => { + const requested = await call(server, 'request_proposals', { + idempotency_key: `proposal-request-finalize-${randomUUID()}`, + brand: BRAND, + brief: 'cross-channel sports', + }); + const draft = (requested.parsed.proposals as Array>)[0]; + expect(draft).toMatchObject({ proposal_status: 'draft' }); + + const payload = { + idempotency_key: `proposal-finalize-${randomUUID()}`, + refinements: [{ proposal_id: draft.proposal_id, action: 'finalize' }], + }; + const first = await call(server, 'refine_proposals', payload); + expect(first.isError).toBeFalsy(); + const firstResult = (first.parsed.results as Array>)[0]; + expect(firstResult).toMatchObject({ + source_proposal_id: draft.proposal_id, + outcome: 'finalized', + proposal: { proposal_status: 'committed', expires_at: expect.any(String) }, + }); + + const replay = await call(server, 'refine_proposals', payload); + expect(replay.parsed.replayed).toBe(true); + expect((replay.parsed.results as Array>)[0]).toEqual(firstResult); + + const secondLogicalFinalize = await call(server, 'refine_proposals', { + ...payload, + idempotency_key: `proposal-finalize-second-${randomUUID()}`, + }); + expect(secondLogicalFinalize.isError).toBe(true); + expect((secondLogicalFinalize.parsed as any).adcp_error?.code).toBe('INVALID_STATE'); + }); + + it('recovers compact finalization when domain state flushes before receipt publication', async () => { + const requested = await call(server, 'request_proposals', { + idempotency_key: `proposal-request-recovery-${randomUUID()}`, + brand: BRAND, + brief: 'cross-channel sports', + }); + const draft = (requested.parsed.proposals as Array>)[0]; + const payload = { + idempotency_key: `proposal-finalize-recovery-${randomUUID()}`, + refinements: [{ proposal_id: draft.proposal_id, action: 'finalize' }], + }; + + const store = getIdempotencyStore(); + const saveFailure = vi.spyOn(store, 'save').mockRejectedValueOnce(new Error('injected finalize receipt failure')); + await expect(call(server, 'refine_proposals', payload)).rejects.toThrow('injected finalize receipt failure'); + saveFailure.mockRestore(); + + const recovered = await call(server, 'refine_proposals', payload); + expect(recovered.isError).toBeFalsy(); + expect((recovered.parsed.results as Array>)[0]).toMatchObject({ + source_proposal_id: draft.proposal_id, + outcome: 'finalized', + proposal: { proposal_status: 'committed' }, + }); + }); + it('validates the complete get_products payload before consulting the cache', async () => { const key = `products-schema-first-${randomUUID()}`; await call(server, 'get_products', { diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index 696404a9fd..75a3f274f9 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -49,6 +49,7 @@ import { TrainingSalesPlatform } from '../../src/training-agent/v6-sales-platfor import { TrainingCreativePlatform } from '../../src/training-agent/v6-creative-platform.js'; import { TrainingCreativeBuilderPlatform } from '../../src/training-agent/v6-creative-builder-platform.js'; import { clearAudienceStore } from '../../src/training-agent/audience-handlers.js'; +import { validateProductDiscoverySourceResponse } from '../../src/training-agent/source-schema.js'; import { projectCreativeForDelivery, projectMediaBuyCreativesForDelivery, @@ -12767,6 +12768,27 @@ describe('proposal lifecycle', () => { const account = { brand: { domain: 'proposal-test.example' }, operator: 'proposal-test.example' }; + async function finalizeCompactProposal( + server: ReturnType, + draft: Record, + ): Promise> { + const { result, isError } = await simulateCallTool(server, 'refine_proposals', { + refinements: [{ proposal_id: draft.proposal_id, action: 'finalize' }], + }); + expect(isError, JSON.stringify(result)).toBeFalsy(); + const finalized = (result.results as Array>)[0]; + expect(finalized).toMatchObject({ + source_proposal_id: draft.proposal_id, + outcome: 'finalized', + proposal: { proposal_status: 'committed', expires_at: expect.any(String) }, + }); + expect( + validateProductDiscoverySourceResponse('refine-proposals-response', result), + JSON.stringify(result), + ).toBeUndefined(); + return finalized.proposal as Record; + } + it('serializes concurrent proposal requests without losing returned snapshots', async () => { const server = createTrainingAgentServer(DEFAULT_CTX); const requests = await Promise.all([ @@ -12797,6 +12819,72 @@ describe('proposal lifecycle', () => { }); }); + it('finalizes drafts into new held snapshots atomically without mutating their sources', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const requested = await Promise.all([ + simulateCallTool(server, 'request_proposals', { + idempotency_key: 'compact-finalize-source-0001', + brand: account.brand, + brief: 'social engagement display', + }), + simulateCallTool(server, 'request_proposals', { + idempotency_key: 'compact-finalize-source-0002', + brand: account.brand, + brief: 'social engagement display', + }), + ]); + const drafts = requested.map(response => ( + response.result.proposals as Array> + )[0]); + expect(drafts).toHaveLength(2); + expect(drafts.every(draft => draft.proposal_status === 'draft')).toBe(true); + + const rejectedBatch = await simulateCallTool(server, 'refine_proposals', { + idempotency_key: 'compact-finalize-atomic-failure-0001', + refinements: [ + { proposal_id: drafts[0]!.proposal_id, action: 'finalize' }, + { proposal_id: 'proposal-not-visible-to-caller', action: 'finalize' }, + ], + }); + expect(rejectedBatch.isError).toBe(true); + expect(rejectedBatch.result).toMatchObject({ code: 'PROPOSAL_NOT_FOUND' }); + + const storedDraftsBeforeFinalize = await runWithSessionContext(async () => { + const session = await getSession( + sessionKeyFromArgs({}, DEFAULT_CTX.mode, undefined, undefined, 'anonymous'), + ); + return structuredClone(session.lastGetProductsContext?.proposals?.filter( + proposal => drafts.some(draft => draft.proposal_id === proposal.proposal_id), + ) ?? []); + }); + + const finalizedBatch = await simulateCallTool(server, 'refine_proposals', { + idempotency_key: 'compact-finalize-atomic-success-0001', + refinements: drafts.map(draft => ({ proposal_id: draft.proposal_id, action: 'finalize' })), + }); + expect(finalizedBatch.isError, JSON.stringify(finalizedBatch.result)).toBeFalsy(); + const results = finalizedBatch.result.results as Array>; + expect(results).toHaveLength(2); + expect(results).toEqual(expect.arrayContaining(drafts.map(draft => expect.objectContaining({ + source_proposal_id: draft.proposal_id, + outcome: 'finalized', + proposal: expect.objectContaining({ proposal_status: 'committed', expires_at: expect.any(String) }), + })))); + + await runWithSessionContext(async () => { + const session = await getSession( + sessionKeyFromArgs({}, DEFAULT_CTX.mode, undefined, undefined, 'anonymous'), + ); + for (const [index, draft] of drafts.entries()) { + const storedSource = session.lastGetProductsContext?.proposals?.find( + proposal => proposal.proposal_id === draft.proposal_id, + ); + expect(storedSource).toMatchObject({ proposal_status: 'draft' }); + expect(storedSource).toEqual(storedDraftsBeforeFinalize[index]); + } + }); + }); + it('connects the compact request, refine, and purchase lifecycle', async () => { const server = createTrainingAgentServer(DEFAULT_CTX); const lifecycleOpportunity = { @@ -12813,7 +12901,17 @@ describe('proposal lifecycle', () => { expect(requested).not.toHaveProperty('pagination'); expect(requested).not.toHaveProperty('refinement_applied'); const source = (requested.proposals as Array>)[0]; - expect(source).toMatchObject({ proposal_status: 'committed' }); + expect(source).toMatchObject({ + proposal_kind: 'new_media_buy', + proposal_status: 'draft', + commercial_terms: expect.any(Object), + terms_digest: expect.stringMatching(/^sha256:[A-Za-z0-9_-]{43}$/), + }); + expect(source).not.toHaveProperty('allocations'); + expect( + validateProductDiscoverySourceResponse('request-proposals-response', requested), + JSON.stringify(requested), + ).toBeUndefined(); const { result: refined, isError: refineError } = await simulateCallTool(server, 'refine_proposals', { refinements: [{ @@ -12830,7 +12928,7 @@ describe('proposal lifecycle', () => { expect(refinement).toMatchObject({ source_proposal_id: source.proposal_id, outcome: 'partial', - proposal: { proposal_status: 'committed' }, + proposal: { proposal_status: 'draft' }, }); const revision = refinement.proposal as Record; expect(revision.proposal_id).not.toBe(source.proposal_id); @@ -12838,12 +12936,15 @@ describe('proposal lifecycle', () => { source_proposal_id: 'proposal-not-visible-to-caller', outcome: 'unable', }); + expect(validateProductDiscoverySourceResponse('refine-proposals-response', refined)).toBeUndefined(); - const committed = revision; + const committed = await finalizeCompactProposal(server, revision); expect(committed).toMatchObject({ - proposal_id: revision.proposal_id, proposal_status: 'committed', }); + expect(committed.proposal_id).not.toBe(revision.proposal_id); + expect(committed.commercial_terms).toEqual(revision.commercial_terms); + expect(committed.terms_digest).toBe(revision.terms_digest); const createArgs = { idempotency_key: 'compact-purchase-once-0001', @@ -12913,13 +13014,52 @@ describe('proposal lifecycle', () => { const refineAfterExecution = await simulateCallTool(server, 'refine_proposals', { refinements: [{ proposal_id: committed.proposal_id, - instructions: 'Mint another buyable revision after executing this snapshot.', + change_kind: 'amendment', + instructions: 'Reduce the budget while preserving the accepted flight.', }], }); expect(refineAfterExecution.isError).toBeFalsy(); expect(refineAfterExecution.result).toMatchObject({ - results: [{ source_proposal_id: committed.proposal_id, outcome: 'unable' }], + results: [{ + source_proposal_id: committed.proposal_id, + outcome: expect.stringMatching(/^(revised|partial)$/), + proposal: { + proposal_kind: 'media_buy_update', + proposal_status: 'draft', + parent_proposal_id: committed.proposal_id, + media_buy_id: purchased.media_buy_id, + base_media_buy_revision: 1, + }, + }], + }); + + const cancellationAfterExecution = await simulateCallTool(server, 'refine_proposals', { + refinements: [{ + proposal_id: committed.proposal_id, + change_kind: 'cancellation', + instructions: 'Cancel by mutual agreement before the next billing period.', + }], + }); + expect(cancellationAfterExecution.isError).toBeFalsy(); + expect(cancellationAfterExecution.result).toMatchObject({ + results: [{ + source_proposal_id: committed.proposal_id, + proposal: { + proposal_kind: 'media_buy_cancellation', + proposal_status: 'draft', + commercial_terms: { + cancellation_terms: { + effective_at: expect.any(String), + reason: 'Cancel by mutual agreement before the next billing period.', + }, + }, + }, + }], }); + expect( + validateProductDiscoverySourceResponse('refine-proposals-response', cancellationAfterExecution.result), + JSON.stringify(cancellationAfterExecution.result), + ).toBeUndefined(); const secondExecution = await simulateCallTool(server, 'create_media_buy', { ...createArgs, @@ -12943,7 +13083,8 @@ describe('proposal lifecycle', () => { brand: account.brand, brief: 'social engagement display', }); - const committed = (requested.proposals as Array>)[0]; + const draft = (requested.proposals as Array>)[0]; + const committed = await finalizeCompactProposal(server, draft); const base = { account, brand: account.brand, @@ -12980,7 +13121,8 @@ describe('proposal lifecycle', () => { brand: account.brand, brief: 'social engagement display', }); - const committed = (requested.proposals as Array>)[0]; + const draft = (requested.proposals as Array>)[0]; + const committed = await finalizeCompactProposal(server, draft); const [create, decline] = await Promise.all([ simulateCallTool(server, 'create_media_buy', { idempotency_key: `test-${randomUUID()}`, @@ -13233,7 +13375,7 @@ describe('proposal lifecycle', () => { expect(revision).toMatchObject({ source_proposal_id: source.proposal_id, outcome: 'revised', - proposal: { proposal_status: 'committed' }, + proposal: { proposal_status: 'draft' }, }); expect(revision).not.toHaveProperty('notes'); }); @@ -13249,7 +13391,8 @@ describe('proposal lifecycle', () => { brief: 'social engagement display', }); expect(requestError).toBeFalsy(); - const committed = (requested.proposals as Array>)[0]; + const draft = (requested.proposals as Array>)[0]; + const committed = await finalizeCompactProposal(server, draft); const purchase = (billingAccount: string, brand: typeof originalBrand) => simulateCallTool( server, diff --git a/static/schemas/source/core/canonical-proposal.json b/static/schemas/source/core/canonical-proposal.json index e606c120d2..f7a6f500fc 100644 --- a/static/schemas/source/core/canonical-proposal.json +++ b/static/schemas/source/core/canonical-proposal.json @@ -18,9 +18,16 @@ "description": "Buyer planning cycle associated with this proposal. Revisions inherit it; it does not participate in proposal identity." }, "base_media_buy_revision": { "type": "integer", "minimum": 1 }, - "proposal_status": { "$ref": "/schemas/enums/proposal-status.json" }, + "proposal_status": { + "$ref": "/schemas/enums/proposal-status.json", + "description": "draft is indicative and unreserved; committed has firm terms with inventory reserved until expires_at; accepted is the historical snapshot attached to a MediaBuy." + }, "accepted_at": { "type": "string", "format": "date-time" }, - "expires_at": { "type": "string", "format": "date-time" }, + "expires_at": { + "type": "string", + "format": "date-time", + "description": "For a draft, the indicative-terms freshness deadline. For a committed proposal, the inventory-hold deadline." + }, "name": { "type": "string", "minLength": 1, "maxLength": 500 }, "description": { "type": "string", "maxLength": 2000 }, "brief_alignment": { "type": "string", "maxLength": 2000 }, diff --git a/static/schemas/source/core/x-entity-types.json b/static/schemas/source/core/x-entity-types.json index 673257cca9..eaf386bb5b 100644 --- a/static/schemas/source/core/x-entity-types.json +++ b/static/schemas/source/core/x-entity-types.json @@ -58,7 +58,7 @@ "media_buy": "A media buy / campaign. `media_buy_id` across media-buy/* schemas.", "package": "A line item within a media buy. `package_id` across media-buy/* schemas.", "product": "A seller inventory product. `product_id` across media-buy/* and core/product.json.", - "proposal": "A seller-issued immutable media-plan snapshot. `proposal_id` is returned by request_proposals or refine_proposals and consumed by refine_proposals, decline_proposals, and create_media_buy. Scoped to the issuing seller and authenticated principal.", + "proposal": "A seller-issued immutable media-plan snapshot. `proposal_id` is returned by request_proposals or refine_proposals; drafts are revised or finalized through refine_proposals, while committed snapshots are consumed by accept_proposal or the create_media_buy compatibility facade. Scoped to the issuing seller and authenticated principal.", "opportunity": "A buyer-assigned planning cycle spanning proposal request, decline, and media-buy creation. `opportunity_id` is scoped to the seller and account and is not proposal identity.", "placement": "A public ad placement within a publisher namespace. Identified by the composite of `publisher_domain` plus `placement_id` in core/placement-ref.json and core/placement.json; legacy single-publisher contexts may omit publisher_domain but the identifier is still placement-scoped, not product-scoped.", "product_pricing_option": "A pricing tier on a seller's inventory product (CPM / CPC / CPCV / etc). `pricing_option_id` inside `core/package.json` and `media-buy/package-request.json`. Scoped to the seller's product rate card — not interchangeable with `vendor_pricing_option`.", diff --git a/static/schemas/source/enums/error-code.json b/static/schemas/source/enums/error-code.json index e19fd56bbd..607211b992 100644 --- a/static/schemas/source/enums/error-code.json +++ b/static/schemas/source/enums/error-code.json @@ -58,7 +58,6 @@ "PRODUCT_EXPIRED", "PROPOSAL_NOT_COMMITTED", "PROPOSAL_NOT_FOUND", - "PROPOSAL_UNAVAILABLE", "MULTI_FINALIZE_UNSUPPORTED", "IO_REQUIRED", "TERMS_REJECTED", @@ -117,7 +116,7 @@ "POLICY_VIOLATION": "Request violates the seller's content or advertising policies. Recovery: correctable (review policy requirements in the error details).", "PRODUCT_NOT_FOUND": "One or more referenced product IDs are unknown or expired. Recovery: correctable (remove invalid IDs and retry, or re-discover with get_products).", "PRODUCT_UNAVAILABLE": "The requested product is sold out or no longer available. Recovery: correctable (choose a different product).", - "PROPOSAL_EXPIRED": "A referenced proposal ID has passed its expires_at timestamp. Recovery: correctable (call request_proposals to get a fresh executable proposal, or re-discover through the legacy get_products facade).", + "PROPOSAL_EXPIRED": "A referenced proposal ID has passed its expires_at timestamp. For a committed proposal, the inventory hold has lapsed. Recovery: correctable (call request_proposals and finalize a fresh proposal, or re-discover and finalize through the legacy get_products facade).", "BUDGET_TOO_LOW": "Budget is below the seller's minimum. Recovery: correctable (increase budget or check capabilities.media_buy.limits).", "CREATIVE_REJECTED": "Creative failed content policy review. For deadline violations, see CREATIVE_DEADLINE_EXCEEDED. Recovery: correctable (revise the creative per the seller's advertising_policies).", "CREATIVE_LOCALE_NOT_ACCEPTED": "A creative bound to a locale-constrained product format has no materialized variant matching locale_policy.accepted_language_ranges, lacks protocol-declared locale topology, or uses serve_default with a seller-ineligible default variant. Seller ranges use RFC 4647 Basic Filtering and are applied independently for every placement where the assignment may serve, before buyer Lookup, locale_fallbacks, or default selection. Distinct from CREATIVE_REJECTED because this is a mechanically discoverable assignment-eligibility mismatch, not subjective content review. error.field SHOULD point to the offending creative or assignment; error.details SHOULD include format_option_id when present, accepted_language_ranges, available_variant_locales, and placement identity when applicable. Recovery: correctable (supply or assign a matching materialized locale variant, narrow placement scope, choose a compatible format option, or change an ineligible serve_default).", @@ -157,9 +156,8 @@ "SESSION_TERMINATED": "SI session has already been terminated and cannot accept further messages. Recovery: correctable (initiate a new session via si_initiate_session).", "VALIDATION_ERROR": "Request contains invalid field values or violates business rules beyond schema validation. Recovery: correctable (review error details and fix field values).", "PRODUCT_EXPIRED": "One or more referenced products have passed their expires_at timestamp and are no longer available for purchase. Recovery: correctable (re-discover with get_products to find current inventory).", - "PROPOSAL_NOT_COMMITTED": "The referenced legacy get_products proposal has proposal_status 'draft' and cannot be used to create a media buy. Recovery: correctable (finalize it through get_products first). AdCP 3.2 request_proposals and refine_proposals return executable committed snapshots.", - "PROPOSAL_NOT_FOUND": "The referenced proposal_id is not recognized by the seller — it belongs to a different tenant, was never issued, or was evicted from the seller's session cache before consumption. Distinct from `PROPOSAL_EXPIRED` (a known proposal whose `expires_at` window has passed) and `PROPOSAL_NOT_COMMITTED` (a known legacy get_products proposal still in `draft`). Recovery: correctable (request a fresh executable proposal, then retry accept_proposal; 3.x compatibility callers retry create_media_buy).", - "PROPOSAL_UNAVAILABLE": "An unexpired committed proposal cannot be accepted because inventory is no longer available and the seller did not reserve it at proposal time. The proposal was valid when issued; the seller has not reserved the inventory and the available slots have since sold. Distinct from `PROPOSAL_EXPIRED` (expires_at window passed) and `PROPOSAL_NOT_FOUND` (unknown proposal_id). Recovery: correctable (call request_proposals to obtain a fresh proposal; consider asking the seller about hold options in the request brief).", + "PROPOSAL_NOT_COMMITTED": "The referenced proposal has proposal_status 'draft' and cannot be accepted into a media buy. Recovery: correctable (finalize it through refine_proposals, or through the legacy get_products refine action).", + "PROPOSAL_NOT_FOUND": "The referenced proposal_id is not recognized by the seller — it belongs to a different tenant, was never issued, or was evicted from the seller's session cache before consumption. Distinct from `PROPOSAL_EXPIRED` (a known proposal whose `expires_at` window has passed) and `PROPOSAL_NOT_COMMITTED` (a known proposal still in `draft`). Recovery: correctable (request a fresh proposal, finalize it, then retry accept_proposal; 3.x compatibility callers retry create_media_buy).", "MULTI_FINALIZE_UNSUPPORTED": "Returned by sellers that cannot guarantee atomic commit across multiple proposals in a single `get_products` call (multiple `action: 'finalize'` entries in `refine[]` targeting different `proposal_id` values). The buyer's intent — atomic multi-proposal finalize — is structurally well-formed and per spec atomic, but this seller's downstream stack cannot satisfy the atomicity guarantee (e.g., the proposals route to two different ad servers with no 2PC). More specific than `INVALID_REQUEST` so buyers can distinguish 'this seller doesn't support multi-finalize' from 'the request itself is malformed'. Recovery: correctable (sequence the finalize calls one-`proposal_id`-per-call; there is no capability flag for multi-finalize support, so a successful first-attempt is the only positive discovery signal). See [refinement guide § Finalize is exclusive](/docs/media-buy/product-discovery/refinement#finalize-is-exclusive-within-refine).", "IO_REQUIRED": "The committed proposal requires a signed insertion order but no io_acceptance was provided. Recovery: correctable (review the proposal's insertion_order, accept terms, and include io_acceptance on accept_proposal; 3.x compatibility callers use create_media_buy).", "TERMS_REJECTED": "Buyer-proposed measurement_terms were rejected by the seller. The error details SHOULD identify which specific term was rejected and the seller's acceptable range or supported vendors. Recovery: correctable (adjust the proposed terms and retry, or omit measurement_terms to accept the product's defaults).", @@ -254,7 +252,7 @@ }, "PROPOSAL_EXPIRED": { "recovery": "correctable", - "suggestion": "call request_proposals for a fresh executable proposal, or re-discover through legacy get_products" + "suggestion": "call request_proposals and finalize a fresh proposal, or re-discover and finalize through legacy get_products" }, "BUDGET_TOO_LOW": { "recovery": "correctable", @@ -414,15 +412,11 @@ }, "PROPOSAL_NOT_COMMITTED": { "recovery": "correctable", - "suggestion": "finalize the legacy draft through get_products, or request a fresh executable proposal" + "suggestion": "finalize the draft through refine_proposals, or through the legacy get_products refine action" }, "PROPOSAL_NOT_FOUND": { "recovery": "correctable", - "suggestion": "request a fresh executable proposal, then retry" - }, - "PROPOSAL_UNAVAILABLE": { - "recovery": "correctable", - "suggestion": "call request_proposals for a fresh executable proposal; consider requesting a hold in the brief if inventory reservation is required" + "suggestion": "request and finalize a fresh proposal, then retry" }, "MULTI_FINALIZE_UNSUPPORTED": { "recovery": "correctable", diff --git a/static/schemas/source/enums/proposal-status.json b/static/schemas/source/enums/proposal-status.json index ed39393ed6..d726784b1a 100644 --- a/static/schemas/source/enums/proposal-status.json +++ b/static/schemas/source/enums/proposal-status.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/enums/proposal-status.json", "title": "Proposal Status", - "description": "Lifecycle status of an immutable commercial snapshot. 'draft' is indicative, 'committed' is a firm offer awaiting buyer acceptance, and 'accepted' is the historical terms snapshot attached to a MediaBuy. Refining an accepted snapshot creates a new proposal ID; it never mutates or reopens the accepted snapshot.", + "description": "Lifecycle status of an immutable commercial snapshot. 'draft' is indicative, 'committed' carries firm pricing with inventory reserved until expires_at, and 'accepted' is the historical terms snapshot attached to a MediaBuy. Refining an accepted snapshot creates a new proposal ID; it never mutates or reopens the accepted snapshot.", "type": "string", "enum": [ "draft", @@ -10,8 +10,8 @@ "accepted" ], "enumDescriptions": { - "draft": "Indicative pricing and availability. The buyer can compare and plan but must request a committed revision before purchasing. The legacy get_products facade may use its 'finalize' refine action; refine_proposals returns committed revisions directly.", - "committed": "Firm, executable terms. The buyer can accept this proposal via accept_proposal before expires_at. The status does not by itself promise that inventory is reserved; a seller that provides a hold must state it in the proposal terms. A seller that has not reserved inventory MAY reject an unexpired committed proposal at acceptance via PROPOSAL_UNAVAILABLE; in that case, availability risk before acceptance is on the buyer.", + "draft": "Indicative pricing and availability. The buyer can compare and revise the proposal but must finalize it before purchasing. Use refine_proposals with action 'finalize'; the legacy get_products facade uses its proposal-scoped 'finalize' refine action.", + "committed": "Firm pricing with inventory reserved. The buyer can accept this proposal via accept_proposal before expires_at. After expires_at, the hold lapses and the buyer must request or finalize a fresh proposal.", "accepted": "Immutable historical commercial terms attached to a MediaBuy. The buyer may pass this proposal_id to refine_proposals to fork an amendment or cancellation proposal." } } diff --git a/static/schemas/source/enums/task-type.json b/static/schemas/source/enums/task-type.json index 8edb16efc8..7b83758097 100644 --- a/static/schemas/source/enums/task-type.json +++ b/static/schemas/source/enums/task-type.json @@ -52,7 +52,7 @@ "activate_signal": "Signals domain: Activate an audience signal on a specific platform or account", "get_products": "Media-buy domain: Discover or refine advertising products and proposals", "request_proposals": "Media-buy domain: Request seller-authored proposals from a campaign brief", - "refine_proposals": "Media-buy domain: Create executable proposal revisions from existing proposals", + "refine_proposals": "Media-buy domain: Create draft revisions or finalize drafts into held committed proposals", "decline_proposals": "Media-buy domain: Terminally decline immutable proposals and prevent later execution", "get_signals": "Signals domain: Discover available audience signals based on natural language description", "create_property_list": "Property domain: Create a new property list with filters and brand reference", diff --git a/static/schemas/source/media-buy/create-media-buy-request.json b/static/schemas/source/media-buy/create-media-buy-request.json index c941b65d33..53aa137c98 100644 --- a/static/schemas/source/media-buy/create-media-buy-request.json +++ b/static/schemas/source/media-buy/create-media-buy-request.json @@ -80,7 +80,7 @@ "proposal_id": { "type": "string", "x-entity": "proposal", - "description": "ID of the exact committed proposal snapshot to execute. With total_budget, the publisher creates packages using the proposal's fixed percentages or seller-optimized constraints. Alternative to providing packages. AdCP 3.2 request_proposals and refine_proposals issue executable committed snapshots; sellers reject declined or previously executed snapshots with INVALID_STATE, while exact retries with the original idempotency key replay historical success. Changed commercial terms are issued under a new proposal_id, so no separate proposal version is required. Legacy get_products drafts remain supported throughout AdCP 3.x and fail with PROPOSAL_NOT_COMMITTED until finalized through that compatibility task." + "description": "ID of the exact committed proposal snapshot to execute. With total_budget, the publisher creates packages using the proposal's fixed percentages or seller-optimized constraints. Alternative to providing packages. AdCP 3.2 request_proposals and ordinary refine_proposals revisions issue drafts; refine_proposals action finalize creates the executable committed hold. Sellers reject draft, declined, or previously executed snapshots, while exact retries with the original idempotency key replay historical success. Changed commercial terms are issued under a new proposal_id, so no separate proposal version is required." }, "opportunity": { "allOf": [ diff --git a/static/schemas/source/media-buy/get-products-request.json b/static/schemas/source/media-buy/get-products-request.json index fdbea00b4b..9b477c12e4 100644 --- a/static/schemas/source/media-buy/get-products-request.json +++ b/static/schemas/source/media-buy/get-products-request.json @@ -145,7 +145,7 @@ "finalize" ], "default": "include", - "description": "'include' (default): return this proposal with updated allocations and pricing. 'omit': exclude this proposal from the response. 'finalize': request firm pricing and inventory hold. New callers use refine_proposals, which returns an executable immutable revision directly; terminal feedback is available through decline_proposals.\n\nLegacy finalize is exclusive within the parent `refine[]` array: see the array-level description for the finalize-exclusivity rule (mixing finalize with non-finalize entries is rejected) and multi-finalize atomicity contract." + "description": "'include' (default): return this proposal with updated allocations and pricing. 'omit': exclude this proposal from the response. 'finalize': request firm pricing and inventory hold. New callers use refine_proposals with action revise for a draft successor or action finalize for a committed held successor; terminal feedback is available through decline_proposals.\n\nLegacy finalize is exclusive within the parent `refine[]` array: see the array-level description for the finalize-exclusivity rule (mixing finalize with non-finalize entries is rejected) and multi-finalize atomicity contract." }, "ask": { "type": "string", diff --git a/static/schemas/source/media-buy/proposal-refinement.json b/static/schemas/source/media-buy/proposal-refinement.json index 0f634a9499..9dee7ae75d 100644 --- a/static/schemas/source/media-buy/proposal-refinement.json +++ b/static/schemas/source/media-buy/proposal-refinement.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/proposal-refinement.json", "title": "Proposal Refinement", - "description": "Fork a draft, committed, or accepted immutable proposal. Refining an accepted proposal creates an amendment or cancellation proposal against its MediaBuy; the source remains accepted and unchanged.", + "description": "Fork an immutable proposal or finalize a draft into a held committed snapshot. Refining an accepted proposal creates a draft amendment or cancellation proposal against its MediaBuy; the source remains accepted and unchanged.", "type": "object", "properties": { "proposal_id": { @@ -10,6 +10,12 @@ "minLength": 1, "x-entity": "proposal" }, + "action": { + "type": "string", + "enum": ["revise", "finalize"], + "default": "revise", + "description": "revise creates a new draft snapshot with changed commercial terms. finalize MUST target a draft; it creates a new committed snapshot without changing terms and reserves inventory until its expires_at." + }, "change_kind": { "type": "string", "enum": ["amendment", "cancellation"], @@ -19,9 +25,25 @@ "instructions": { "type": "string", "minLength": 1, - "description": "Requested commercial changes or cancellation reason. The seller returns a new committed proposal snapshot; this text never directly mutates the source proposal or MediaBuy." + "description": "Requested commercial changes or cancellation reason for action revise. The seller returns a new draft proposal snapshot; this text never directly mutates the source proposal or MediaBuy." } }, - "required": ["proposal_id", "instructions"], + "required": ["proposal_id"], + "oneOf": [ + { + "properties": { "action": { "type": "string", "const": "finalize" } }, + "required": ["action"], + "not": { + "anyOf": [ + { "required": ["change_kind"] }, + { "required": ["instructions"] } + ] + } + }, + { + "properties": { "action": { "type": "string", "const": "revise" } }, + "required": ["instructions"] + } + ], "additionalProperties": false } diff --git a/static/schemas/source/media-buy/refine-proposals-request.json b/static/schemas/source/media-buy/refine-proposals-request.json index ffe6778d0c..f738e98f01 100644 --- a/static/schemas/source/media-buy/refine-proposals-request.json +++ b/static/schemas/source/media-buy/refine-proposals-request.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/refine-proposals-request.json", "title": "Refine Proposals Request", - "description": "Create executable revisions from one or more existing proposal snapshots. Each source remains addressable so buyers can explore alternatives safely.", + "description": "Create draft revisions or finalize draft proposals into held committed snapshots. Each result receives a new proposal_id and every source remains unchanged.", "type": "object", "x-mutates-state": true, "x-operation-family": "refine_proposals", @@ -30,10 +30,26 @@ }, "refinements": { "type": "array", - "description": "Proposal revisions to create. Sources may be draft, committed, or accepted. An accepted source is forked into a MediaBuy amendment or cancellation proposal and remains immutable. proposal_id values MUST be unique within the request; results preserve request order.", + "description": "Proposal operations to apply. revise creates a draft successor from a draft, committed, or accepted source. finalize MUST target a draft, reserves inventory, and creates a committed successor whose expires_at is the hold deadline. A batch containing finalize MUST contain only finalize entries and is atomic. proposal_id values MUST be unique; results preserve request order.", "items": { "$ref": "/schemas/media-buy/proposal-refinement.json" }, "minItems": 1, - "uniqueItems": true + "uniqueItems": true, + "allOf": [ + { + "if": { + "contains": { + "properties": { "action": { "type": "string", "const": "finalize" } }, + "required": ["action"] + } + }, + "then": { + "items": { + "properties": { "action": { "type": "string", "const": "finalize" } }, + "required": ["action"] + } + } + } + ] } }, "required": ["idempotency_key", "refinements"], diff --git a/static/schemas/source/media-buy/refine-proposals-response.json b/static/schemas/source/media-buy/refine-proposals-response.json index da99c731d1..74fcc96f6e 100644 --- a/static/schemas/source/media-buy/refine-proposals-response.json +++ b/static/schemas/source/media-buy/refine-proposals-response.json @@ -2,16 +2,14 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/refine-proposals-response.json", "title": "Refine Proposals Response", - "description": "One ordered immutable revision result per requested source proposal, plus compact canonical products needed to evaluate the revised purchases. Products always carry product_id and name and never carry legacy named-format identifiers.", + "description": "One ordered immutable revision or finalization result per requested source proposal, plus compact canonical products needed to evaluate the resulting terms. Products always carry product_id and name and never carry legacy named-format identifiers.", "type": "object", "anyOf": [ { + "properties": { "status": { "type": "string", "const": "completed" } }, "required": ["results", "products"], "not": { - "anyOf": [ - { "required": ["status"] }, - { "required": ["task_id"] } - ] + "required": ["task_id"] } }, { @@ -19,8 +17,10 @@ } ], "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "results": { "type": "array", + "description": "Ordered results. If any result is finalized, every result MUST be finalized; a finalize batch either creates every requested hold or none.", "minItems": 1, "items": { "type": "object", @@ -35,20 +35,9 @@ }, "outcome": { "type": "string", - "enum": ["revised", "partial", "unable"] - }, - "proposal": { - "allOf": [ - { "$ref": "/schemas/core/canonical-proposal.json" }, - { - "properties": { - "proposal_status": { "type": "string", "const": "committed" }, - "expires_at": { "type": "string", "format": "date-time" } - }, - "required": ["proposal_status", "expires_at", "proposal_kind", "commercial_terms", "terms_digest"] - } - ] + "enum": ["revised", "partial", "finalized", "unable"] }, + "proposal": { "$ref": "/schemas/core/canonical-proposal.json" }, "reason": { "type": "string", "minLength": 1 }, "notes": { "type": "string", "minLength": 1 }, "suggestions": { @@ -60,7 +49,13 @@ "required": ["source_proposal_id", "outcome"], "oneOf": [ { - "properties": { "outcome": { "type": "string", "const": "revised" } }, + "properties": { + "outcome": { "type": "string", "const": "revised" }, + "proposal": { + "properties": { "proposal_status": { "type": "string", "const": "draft" } }, + "required": ["proposal_status"] + } + }, "required": ["outcome", "proposal"], "not": { "anyOf": [ @@ -70,10 +65,35 @@ } }, { - "properties": { "outcome": { "type": "string", "const": "partial" } }, + "properties": { + "outcome": { "type": "string", "const": "partial" }, + "proposal": { + "properties": { "proposal_status": { "type": "string", "const": "draft" } }, + "required": ["proposal_status"] + } + }, "required": ["outcome", "proposal", "notes"], "not": { "required": ["reason"] } }, + { + "properties": { + "outcome": { "type": "string", "const": "finalized" }, + "proposal": { + "properties": { + "proposal_status": { "type": "string", "const": "committed" }, + "expires_at": { "type": "string", "format": "date-time" } + }, + "required": ["proposal_status", "expires_at"] + } + }, + "required": ["outcome", "proposal"], + "not": { + "anyOf": [ + { "required": ["reason"] }, + { "required": ["notes"] } + ] + } + }, { "properties": { "outcome": { "type": "string", "const": "unable" } }, "required": ["outcome", "reason"], @@ -81,13 +101,29 @@ } ], "additionalProperties": false - } + }, + "allOf": [ + { + "if": { + "contains": { + "properties": { "outcome": { "type": "string", "const": "finalized" } }, + "required": ["outcome"] + } + }, + "then": { + "items": { + "properties": { "outcome": { "type": "string", "const": "finalized" } }, + "required": ["outcome"] + } + } + } + ] }, "products": { "type": "array", "items": { "$ref": "/schemas/core/canonical-product.json" } }, - "status": { "type": "string", "const": "submitted" }, + "status": { "type": "string", "enum": ["completed", "submitted"] }, "task_id": { "type": "string", "minLength": 1, "x-entity": "task" }, "message": { "type": "string", "maxLength": 2000 }, "errors": { "type": "array", "items": { "$ref": "/schemas/core/error.json" } }, diff --git a/static/schemas/source/media-buy/request-proposals-request.json b/static/schemas/source/media-buy/request-proposals-request.json index 9384d54dfb..eb0d98ed93 100644 --- a/static/schemas/source/media-buy/request-proposals-request.json +++ b/static/schemas/source/media-buy/request-proposals-request.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/request-proposals-request.json", "title": "Request Proposals Request", - "description": "Ask a seller to create one or more executable media-plan proposals from a brief. Each returned proposal_id identifies one immutable committed snapshot and connects optional refinement, decline, and purchase; changed commercial terms require a new proposal_id.", + "description": "Ask a seller to create one or more indicative media-plan proposals from a brief. Each returned proposal_id identifies one immutable draft snapshot; revise or finalize it through refine_proposals, or decline it through decline_proposals.", "type": "object", "x-mutates-state": true, "x-operation-family": "request_proposals", diff --git a/static/schemas/source/media-buy/request-proposals-response.json b/static/schemas/source/media-buy/request-proposals-response.json index 1cb3c951be..237a7da4ba 100644 --- a/static/schemas/source/media-buy/request-proposals-response.json +++ b/static/schemas/source/media-buy/request-proposals-response.json @@ -2,9 +2,10 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/request-proposals-response.json", "title": "Request Proposals Response", - "description": "One or more executable media-plan proposals and compact canonical products referenced by their purchases. Products always carry product_id and name and never carry legacy named-format identifiers. Returning only products does not satisfy request_proposals.", + "description": "One or more immutable draft media-plan proposals and compact canonical products referenced by their purchases. Products always carry product_id and name and never carry legacy named-format identifiers. Returning only products does not satisfy request_proposals.", "type": "object", "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "outcome": { "type": "string", "enum": ["proposed", "rejected"] }, "reason": { "type": "string", "minLength": 1 }, "suggestions": { @@ -20,7 +21,7 @@ { "$ref": "/schemas/core/canonical-proposal.json" }, { "properties": { - "proposal_status": { "type": "string", "const": "committed" }, + "proposal_status": { "type": "string", "const": "draft" }, "expires_at": { "type": "string", "format": "date-time" } }, "required": ["proposal_status", "expires_at", "proposal_kind", "commercial_terms", "terms_digest"] @@ -33,7 +34,7 @@ "minItems": 1, "items": { "$ref": "/schemas/core/canonical-product.json" } }, - "status": { "type": "string", "const": "submitted" }, + "status": { "type": "string", "enum": ["completed", "submitted"] }, "task_id": { "type": "string", "minLength": 1, "x-entity": "task" }, "message": { "type": "string", "maxLength": 2000 }, "errors": { "type": "array", "items": { "$ref": "/schemas/core/error.json" } }, @@ -43,25 +44,29 @@ }, "anyOf": [ { - "properties": { "outcome": { "type": "string", "const": "proposed" } }, + "properties": { + "outcome": { "type": "string", "const": "proposed" }, + "status": { "type": "string", "const": "completed" } + }, "required": ["outcome", "proposals", "products"], "not": { "anyOf": [ { "required": ["reason"] }, { "required": ["suggestions"] }, - { "required": ["status"] }, { "required": ["task_id"] } ] } }, { - "properties": { "outcome": { "type": "string", "const": "rejected" } }, + "properties": { + "outcome": { "type": "string", "const": "rejected" }, + "status": { "type": "string", "const": "completed" } + }, "required": ["outcome", "reason"], "not": { "anyOf": [ { "required": ["proposals"] }, { "required": ["products"] }, - { "required": ["status"] }, { "required": ["task_id"] } ] } diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index e9f24e8464..5d0552dc25 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -710,7 +710,7 @@ }, "supports_proposals": { "type": "boolean", - "description": "Conformance declaration that this seller supports actionable proposals through either the compact request/refine lifecycle or the legacy get_products facade. create_media_buy consumes an executable proposal_id before expires_at.", + "description": "Conformance declaration that this seller supports proposals through either the compact request/refine/finalize lifecycle or the legacy get_products facade. accept_proposal, or the create_media_buy compatibility facade, consumes a finalized committed proposal_id before expires_at.", "default": false }, "governance_aware": { diff --git a/tests/composed-schema-validation.test.cjs b/tests/composed-schema-validation.test.cjs index 137f08ab17..66404167ab 100644 --- a/tests/composed-schema-validation.test.cjs +++ b/tests/composed-schema-validation.test.cjs @@ -3125,13 +3125,24 @@ async function runTests() { }, 'refine_proposals forks an accepted proposal into a cancellation proposal' ); - await testSchemaRejection( + await testSchemaValidation( '/schemas/media-buy/refine-proposals-request.json', { idempotency_key: 'refine-proposals-0002', refinements: [{ proposal_id: 'proposal-1', action: 'finalize' }] }, - 'refine_proposals rejects finalization' + 'refine_proposals accepts explicit finalization without fake revision instructions' + ); + await testSchemaRejection( + '/schemas/media-buy/refine-proposals-request.json', + { + idempotency_key: 'refine-proposals-mixed-finalize-0001', + refinements: [ + { proposal_id: 'proposal-1', action: 'finalize' }, + { proposal_id: 'proposal-2', instructions: 'Change the budget.' } + ] + }, + 'refine_proposals keeps finalize batches exclusive and atomic' ); const cleanPurchase = { idempotency_key: 'buy-products-clean-0001', @@ -3532,7 +3543,7 @@ async function runTests() { proposal_id: 'proposal-1', name: 'Draft premium video plan', proposal_kind: 'new_media_buy', - proposal_status: 'committed', + proposal_status: 'draft', expires_at: '2027-06-30T23:59:59Z', commercial_terms: { brand: { domain: 'buyer.example' }, @@ -3600,6 +3611,72 @@ async function runTests() { }, 'refine_proposals partial results require explanatory notes' ); + await testSchemaValidation( + '/schemas/media-buy/refine-proposals-response.json', + { + results: [{ + source_proposal_id: 'proposal-draft-1', + outcome: 'finalized', + proposal: { + proposal_id: 'proposal-committed-1', + proposal_kind: 'new_media_buy', + proposal_status: 'committed', + expires_at: '2027-06-30T23:59:59Z', + name: 'Held premium video plan', + commercial_terms: { + brand: { domain: 'buyer.example' }, + purchases: [{ + product_id: 'premium-video', + pricing_option_id: 'fixed-cpm', + pricing: { pricing_option_id: 'fixed-cpm', pricing_model: 'cpm', currency: 'USD', fixed_price: 28 }, + start_time: '2027-06-01T12:00:00Z', + end_time: '2027-07-01T00:00:00Z' + }], + start_time: 'asap', + end_time: '2027-07-01T00:00:00Z' + }, + terms_digest: `sha256:${'A'.repeat(43)}` + } + }], + products: [] + }, + 'refine_proposals finalization returns a committed proposal with a hold deadline' + ); + await testSchemaRejection( + '/schemas/media-buy/refine-proposals-response.json', + { + results: [{ + source_proposal_id: 'proposal-draft-1', + outcome: 'finalized', + proposal: { + proposal_id: 'proposal-committed-1', + proposal_kind: 'new_media_buy', + proposal_status: 'committed', + expires_at: '2027-06-30T23:59:59Z', + name: 'Held premium video plan', + commercial_terms: { + brand: { domain: 'buyer.example' }, + purchases: [{ + product_id: 'premium-video', + pricing_option_id: 'fixed-cpm', + pricing: { pricing_option_id: 'fixed-cpm', pricing_model: 'cpm', currency: 'USD', fixed_price: 28 }, + start_time: '2027-06-01T12:00:00Z', + end_time: '2027-07-01T00:00:00Z' + }], + start_time: '2027-06-01T12:00:00Z', + end_time: '2027-07-01T00:00:00Z' + }, + terms_digest: `sha256:${'A'.repeat(43)}` + } + }, { + source_proposal_id: 'proposal-draft-2', + outcome: 'unable', + reason: 'Inventory could not be held.' + }], + products: [] + }, + 'refine_proposals forbids partially successful atomic finalization batches' + ); log(''); log('SignalId compatibility during SignalRef migration:', 'info'); From 4deaa0917ab5fb1b87e1a1aa68ffa67092ffce77 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 12 Aug 2026 22:40:02 +0200 Subject: [PATCH 41/49] fix(media-buy): discriminate proposal refinements --- .../task-reference/refine_proposals.mdx | 5 ++++- server/src/training-agent/task-handlers.ts | 4 ++-- .../integration/training-agent-webhooks.test.ts | 1 + .../unit/training-agent-idempotency.test.ts | 4 ++-- server/tests/unit/training-agent.test.ts | 7 +++++++ .../source/media-buy/proposal-refinement.json | 3 +-- tests/composed-schema-validation.test.cjs | 17 ++++++++++++++--- 7 files changed, 31 insertions(+), 10 deletions(-) diff --git a/docs/media-buy/task-reference/refine_proposals.mdx b/docs/media-buy/task-reference/refine_proposals.mdx index 6b21cdd8dc..b7c6503ba8 100644 --- a/docs/media-buy/task-reference/refine_proposals.mdx +++ b/docs/media-buy/task-reference/refine_proposals.mdx @@ -7,7 +7,7 @@ testable: false `refine_proposals` has two explicit operations over immutable proposal snapshots: -- `action: "revise"` (the default) applies `instructions` and returns a new draft proposal. +- `action: "revise"` applies `instructions` and returns a new draft proposal. - `action: "finalize"` changes no commercial terms and returns a new committed proposal backed by an inventory hold until `expires_at`. Revision may target a draft, committed, or accepted proposal; finalization must target a draft. Proposal IDs must be unique within the request, and results preserve request order. Every successful operation returns a new `proposal_id`; that ID is the complete version identity for the immutable commercial snapshot, and the source remains unchanged. @@ -20,10 +20,12 @@ Revision may target a draft, committed, or accepted proposal; finalization must "refinements": [ { "proposal_id": "proposal_123", + "action": "revise", "instructions": "Shift more budget toward video." }, { "proposal_id": "proposal_456", + "action": "revise", "instructions": "Limit the revision to inventory available in the US and Canada." } ] @@ -62,6 +64,7 @@ An accepted proposal is the immutable commercial envelope attached to its MediaB "refinements": [ { "proposal_id": "proposal_accepted_123", + "action": "revise", "change_kind": "amendment", "instructions": "Extend the flight by two weeks and add the premium video product." } diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 8a4f75e6cb..35a4b73130 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -4773,9 +4773,9 @@ export function validateProductDiscoveryAliasInput( return { message: 'each revision requires instructions', field: `refinements[${index}].instructions` }; } } - if (entry.action !== undefined && entry.action !== 'revise' && entry.action !== 'finalize') { + if (entry.action !== 'revise' && entry.action !== 'finalize') { return { - message: 'action must be revise or finalize', + message: 'action is required and must be revise or finalize', field: `refinements[${index}].action`, }; } diff --git a/server/tests/integration/training-agent-webhooks.test.ts b/server/tests/integration/training-agent-webhooks.test.ts index 5424933002..e27918a518 100644 --- a/server/tests/integration/training-agent-webhooks.test.ts +++ b/server/tests/integration/training-agent-webhooks.test.ts @@ -231,6 +231,7 @@ describe('Training Agent webhook emission', () => { idempotency_key: `split-refine-${randomUUID()}`, refinements: [{ proposal_id: source.proposal_id, + action: 'revise', instructions: 'Prefer social inventory while preserving the total budget.', }], push_notification_config: callback('op_refine_proposals'), diff --git a/server/tests/unit/training-agent-idempotency.test.ts b/server/tests/unit/training-agent-idempotency.test.ts index 18c3fa7c9d..3ea6f473ae 100644 --- a/server/tests/unit/training-agent-idempotency.test.ts +++ b/server/tests/unit/training-agent-idempotency.test.ts @@ -613,13 +613,13 @@ describe('training agent idempotency middleware', () => { const key = `proposal-refine-${randomUUID()}`; const first = await call(server, 'refine_proposals', { idempotency_key: key, - refinements: [{ proposal_id: proposalId, instructions: 'Prefer video.' }], + refinements: [{ proposal_id: proposalId, action: 'revise', instructions: 'Prefer video.' }], }); expect(first.isError).toBeFalsy(); const conflict = await call(server, 'refine_proposals', { idempotency_key: key, - refinements: [{ proposal_id: proposalId, instructions: 'Prefer audio.' }], + refinements: [{ proposal_id: proposalId, action: 'revise', instructions: 'Prefer audio.' }], }); expect(conflict.isError).toBe(true); expect((conflict.parsed as any).adcp_error?.code).toBe('IDEMPOTENCY_CONFLICT'); diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index 75a3f274f9..ecdf3afb5c 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -12916,10 +12916,12 @@ describe('proposal lifecycle', () => { const { result: refined, isError: refineError } = await simulateCallTool(server, 'refine_proposals', { refinements: [{ proposal_id: source.proposal_id, + action: 'revise', change_kind: 'amendment', instructions: 'Prefer the social inventory while preserving the total budget.', }, { proposal_id: 'proposal-not-visible-to-caller', + action: 'revise', instructions: 'Use a proposal that is not available in this principal scope.', }], }); @@ -13014,6 +13016,7 @@ describe('proposal lifecycle', () => { const refineAfterExecution = await simulateCallTool(server, 'refine_proposals', { refinements: [{ proposal_id: committed.proposal_id, + action: 'revise', change_kind: 'amendment', instructions: 'Reduce the budget while preserving the accepted flight.', }], @@ -13036,6 +13039,7 @@ describe('proposal lifecycle', () => { const cancellationAfterExecution = await simulateCallTool(server, 'refine_proposals', { refinements: [{ proposal_id: committed.proposal_id, + action: 'revise', change_kind: 'cancellation', instructions: 'Cancel by mutual agreement before the next billing period.', }], @@ -13171,6 +13175,7 @@ describe('proposal lifecycle', () => { const { result: refined, isError: refineError } = await simulateCallTool(server, 'refine_proposals', { refinements: [{ proposal_id: source.proposal_id, + action: 'revise', instructions: 'Prefer social inventory without changing the planning cycle.', }], }); @@ -13269,6 +13274,7 @@ describe('proposal lifecycle', () => { const refineAfterDecline = await simulateCallTool(server, 'refine_proposals', { refinements: [{ proposal_id: committed.proposal_id, + action: 'revise', instructions: 'Try a different allocation after terminal decline.', }], }); @@ -13367,6 +13373,7 @@ describe('proposal lifecycle', () => { const { result: refined, isError: refineError } = await simulateCallTool(server, 'refine_proposals', { refinements: [{ proposal_id: source.proposal_id, + action: 'revise', instructions: 'Provide concrete fixed CPM pricing in USD.', }], }); diff --git a/static/schemas/source/media-buy/proposal-refinement.json b/static/schemas/source/media-buy/proposal-refinement.json index 9dee7ae75d..56e29596a9 100644 --- a/static/schemas/source/media-buy/proposal-refinement.json +++ b/static/schemas/source/media-buy/proposal-refinement.json @@ -13,7 +13,6 @@ "action": { "type": "string", "enum": ["revise", "finalize"], - "default": "revise", "description": "revise creates a new draft snapshot with changed commercial terms. finalize MUST target a draft; it creates a new committed snapshot without changing terms and reserves inventory until its expires_at." }, "change_kind": { @@ -42,7 +41,7 @@ }, { "properties": { "action": { "type": "string", "const": "revise" } }, - "required": ["instructions"] + "required": ["action", "instructions"] } ], "additionalProperties": false diff --git a/tests/composed-schema-validation.test.cjs b/tests/composed-schema-validation.test.cjs index 66404167ab..0f9b747f8a 100644 --- a/tests/composed-schema-validation.test.cjs +++ b/tests/composed-schema-validation.test.cjs @@ -3107,18 +3107,29 @@ async function runTests() { { idempotency_key: 'refine-proposals-0001', refinements: [ - { proposal_id: 'proposal-1', instructions: 'Prefer video and move budget toward it' }, - { proposal_id: 'proposal-2', instructions: 'Use only the premium video product' } + { proposal_id: 'proposal-1', action: 'revise', instructions: 'Prefer video and move budget toward it' }, + { proposal_id: 'proposal-2', action: 'revise', instructions: 'Use only the premium video product' } ] }, 'refine_proposals accepts plural proposal-scoped immutable refinements' ); + await testSchemaRejection( + '/schemas/media-buy/refine-proposals-request.json', + { + idempotency_key: 'refine-proposals-missing-action-0001', + refinements: [ + { proposal_id: 'proposal-1', instructions: 'Prefer video and move budget toward it' } + ] + }, + 'refine_proposals requires an explicit action discriminator' + ); await testSchemaValidation( '/schemas/media-buy/refine-proposals-request.json', { idempotency_key: 'refine-accepted-cancel-0001', refinements: [{ proposal_id: 'accepted-proposal-1', + action: 'revise', change_kind: 'cancellation', instructions: 'Cancel at the earliest date permitted by the accepted terms.' }] @@ -3139,7 +3150,7 @@ async function runTests() { idempotency_key: 'refine-proposals-mixed-finalize-0001', refinements: [ { proposal_id: 'proposal-1', action: 'finalize' }, - { proposal_id: 'proposal-2', instructions: 'Change the budget.' } + { proposal_id: 'proposal-2', action: 'revise', instructions: 'Change the budget.' } ] }, 'refine_proposals keeps finalize batches exclusive and atomic' From 792c0e983e97a55caaee5475770597770b1a5746 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 13 Aug 2026 11:37:06 +0200 Subject: [PATCH 42/49] feat: scope brand and operator account identities --- .changeset/secure-get-products-idempotency.md | 2 + docs/accounts/overview.mdx | 6 +- docs/accounts/tasks/sync_accounts.mdx | 10 ++- docs/building/by-layer/L1/security.mdx | 2 +- .../by-layer/L2/accounts-and-agents.mdx | 15 ++-- .../task-reference/request_proposals.mdx | 8 +- docs/protocol/calling-an-agent.mdx | 2 +- server/src/training-agent/account-handlers.ts | 83 +++++++++++++++---- server/src/training-agent/account-scope.ts | 38 ++++++++- server/src/training-agent/task-handlers.ts | 34 ++++++-- server/src/training-agent/types.ts | 4 +- server/tests/unit/account-handlers.test.ts | 34 +++++++- .../unit/training-agent-account-scope.test.ts | 23 ++++- .../source/account/sync-accounts-request.json | 18 +++- static/schemas/source/core/account-ref.json | 10 ++- static/schemas/source/core/account.json | 7 ++ static/schemas/source/core/brand-key.json | 7 +- static/schemas/source/core/brand-ref.json | 9 ++ .../source/core/canonical-account-ref.json | 17 +++- .../media-buy/list-products-request.json | 6 +- tests/composed-schema-validation.test.cjs | 31 ++++++- 21 files changed, 308 insertions(+), 58 deletions(-) diff --git a/.changeset/secure-get-products-idempotency.md b/.changeset/secure-get-products-idempotency.md index c89d74a1f0..51481c8771 100644 --- a/.changeset/secure-get-products-idempotency.md +++ b/.changeset/secure-get-products-idempotency.md @@ -5,3 +5,5 @@ Add the compact AdCP 3.2 product and MediaBuy lifecycle: `list_products`, `request_proposals`, `refine_proposals`, `decline_proposals`, `buy_products`, `accept_proposal`, and `control_media_buy`. The task-specific contracts separate offer discovery, immutable draft proposal creation, explicit finalization with inventory reservation, terminal decline, direct purchase, proposal acceptance, and operational delivery control while retaining `get_products`, `create_media_buy`, and `update_media_buy` as compatibility facades throughout 3.x. New purchase and control inputs never accept inline creatives; commercial amendments and negotiated cancellations fork an accepted proposal, while operational controls remain revision-checked. Compact purchase snapshots preserve resolved package flight, billing-measurement, performance, and reporting terms. A shared opportunity reference connects planning-cycle context through purchase without duplicating proposal version identity. Canonical inputs use stable brand keys, catalog references, and compact account and optimization types so brand assets, legacy named formats, creative provenance, and compliance payloads do not transitively enter the clean tools. Publish machine-readable SDK fallback grades and task-result schema resolution, plus MCP production, media-buy, and creative catalogs that remove presentation annotations without changing validation semantics. The role catalogs select active 3.2 seller-hosted operations and publish client-side input-only prompt views while retaining output and terminal task-result schemas in their parent validation catalogs. Preserve the AdCP 3.1 inventory-reservation contract in the compact lifecycle: requested and revised proposals remain immutable drafts until `refine_proposals` finalizes them, and a finalized `committed` snapshot guarantees inventory is held until `expires_at`. Add `countries` and `property_list` product-attribute filters, and publish compact task-specific async envelopes for consultative proposal planning and re-underwriting. + +Allow compact BrandKeys to identify a country market and compact natural accounts to identify an operator business region, keeping regional brand/operator account relationships distinct without adding delivery targeting to identity fields. diff --git a/docs/accounts/overview.mdx b/docs/accounts/overview.mdx index 5a15806b42..4261c59290 100644 --- a/docs/accounts/overview.mdx +++ b/docs/accounts/overview.mdx @@ -43,7 +43,7 @@ The Accounts Protocol applies across all vendor protocols. An orchestrator estab | Governance | Content standards billing | | Creative | Creative service billing | -The account reference may be a seller-assigned `account_id` (seller-owned namespaces, usually `require_operator_auth: true`) or a natural key — `brand` + `operator` (buyer-declared accounts, `require_operator_auth: false`). For buyer-declared accounts, the natural-key `AccountRef` MUST remain valid on subsequent calls even if the seller also echoes an internal `account_id`. For sandbox, account-id namespaces use pre-existing test accounts discovered via `list_accounts` or supplied out-of-band, while buyer-declared accounts declare sandbox via `sync_accounts` with `sandbox: true`. See [Account references](/docs/building/by-layer/L2/accounts-and-agents#account-references) for details. +The account reference may be a seller-assigned `account_id` (seller-owned namespaces, usually `require_operator_auth: true`) or a natural key — `brand` + `operator` + optional `operator_region` (buyer-declared accounts, `require_operator_auth: false`). `brand.market` may independently distinguish a country-market identity. For buyer-declared accounts, the complete natural-key `AccountRef` MUST remain valid on subsequent calls even if the seller also echoes an internal `account_id`. For sandbox, account-id namespaces use pre-existing test accounts discovered via `list_accounts` or supplied out-of-band, while buyer-declared accounts declare sandbox via `sync_accounts` with `sandbox: true`. See [Account references](/docs/building/by-layer/L2/accounts-and-agents#account-references) for details. ## Account Status Lifecycle @@ -267,14 +267,14 @@ The Accounts Protocol operates with four party types. See [Accounts and agents]( | Party | Role | Identified by | |-----------|------|---------------| -| Brand | Whose products are advertised | `brand.domain` + optional `brand.brand_id` via brand.json | +| Brand | Whose products are advertised | `brand.domain` + optional `brand.brand_id`/`brand.market` via brand.json | | Operator | Who drives the buys | Domain (e.g., `pinnacle-media.com`) | | Agent | What software places the buys | Authenticated session | | Vendor agent | The seller's AdCP agent | `agent_url` | ## Tasks -**Account discovery (normative).** Every agent accepting accounts MUST expose at least one of `list_accounts` (upstream-managed account-id namespaces) or `sync_accounts` (buyer-declared accounts, `require_operator_auth: false`). A seller-defined account-id namespace MAY omit both account-discovery tasks only when account IDs are supplied out-of-band and no account settings are managed through AdCP. Buyer-declared-account sellers SHOULD expose `list_accounts` in addition to `sync_accounts`: a stateless buyer cannot recover its acknowledged portfolio by re-running `sync_accounts` when the lost state is the set of `(brand, operator, sandbox)` natural keys needed to construct that request. For account-id namespaces, `sync_accounts` is settings-update only in 3.0.x unless a future explicit capability declares account-id provisioning. See [Required tasks by protocol](/docs/protocol/required-tasks#any-agent-accepting-accounts). +**Account discovery (normative).** Every agent accepting accounts MUST expose at least one of `list_accounts` (upstream-managed account-id namespaces) or `sync_accounts` (buyer-declared accounts, `require_operator_auth: false`). A seller-defined account-id namespace MAY omit both account-discovery tasks only when account IDs are supplied out-of-band and no account settings are managed through AdCP. Buyer-declared-account sellers SHOULD expose `list_accounts` in addition to `sync_accounts`: a stateless buyer cannot recover its acknowledged portfolio by re-running `sync_accounts` when the lost state is the set of `(brand, operator, operator_region, sandbox)` natural keys needed to construct that request. For account-id namespaces, `sync_accounts` is settings-update only in 3.0.x unless a future explicit capability declares account-id provisioning. See [Required tasks by protocol](/docs/protocol/required-tasks#any-agent-accepting-accounts). | Task | Purpose | |------|--------| diff --git a/docs/accounts/tasks/sync_accounts.mdx b/docs/accounts/tasks/sync_accounts.mdx index 08a51f407e..493c7c622b 100644 --- a/docs/accounts/tasks/sync_accounts.mdx +++ b/docs/accounts/tasks/sync_accounts.mdx @@ -5,7 +5,7 @@ description: "sync_accounts provisions buyer-declared accounts or updates settin testable: false --- -Sync advertiser accounts with a seller for one or more brand/operator pairs, or update settings on existing accounts when the seller exposes that mode. Brands are identified by a `brand` object containing `domain` + optional `brand_id`, resolved via `/.well-known/brand.json`. +Sync advertiser accounts with a seller for one or more brand/operator relationships, or update settings on existing accounts when the seller exposes that mode. Brands use `domain` plus optional `brand_id` and ISO country `market`; `operator_region` can independently identify the operator's business region. `sync_accounts` is used across all seller protocols: media buy agents, signals agents, governance agents, and creative agents. In provisioning mode it declares the buyer's intent — the seller provisions or links accounts internally. Use provisioning mode for buyer-declared accounts (`require_operator_auth: false`) and use natural keys (`brand` + `operator`) on subsequent requests. Sellers MAY echo an `account_id` as an internal handle, but they MUST continue accepting the natural-key `AccountRef` for accounts provisioned this way. For account-id namespaces, discover seller-assigned account IDs via [`list_accounts`](/docs/accounts/tasks/list_accounts) or out-of-band onboarding; `sync_accounts` provisioning for account-id namespaces is out of scope unless a future explicit capability declares that mode. If such a seller exposes `sync_accounts` today, use only settings-update mode keyed by `account_id`. @@ -95,15 +95,16 @@ asyncio.run(main()) | Field | Type | Required | Description | |-------|------|----------|-------------| -| `brand` | object | Yes | Brand reference identifying the advertiser. Contains `domain` (house domain where brand.json is hosted) and optional `brand_id` (for multi-brand houses). See [brand-ref](/docs/brand-protocol/brand-json). | +| `brand` | object | Yes | Brand reference identifying the advertiser. Contains `domain`, optional `brand_id`, and optional ISO country `market`. See [brand-ref](/docs/brand-protocol/brand-json). | | `operator` | string | Yes | Domain of the entity operating on the brand's behalf (e.g. `pinnacle-media.com`). When the brand operates directly, set to the brand's domain. Verified against the brand's `authorized_operators` in brand.json. | +| `operator_region` | string | No | Stable operator-defined business region such as `emea`. Part of the natural key; not delivery targeting. | | `billing` | string | Yes | Who should be invoiced: `operator`, `agent`, or `advertiser`. Check [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities) for `supported_billing` to see what the seller accepts at the capability level. The seller must either accept this billing model or reject the request. Sellers MAY additionally reject a value the seller-wide capability accepts when the calling buyer agent's commercial relationship does not permit it — e.g., a buyer agent onboarded as passthrough-only (no payments relationship — only the operator can be invoiced). The two gates use distinct error codes — [`BILLING_NOT_SUPPORTED`](/docs/building/verification/compliance-catalog#error-code-billing-not-supported) for the seller-wide capability gate, [`BILLING_NOT_PERMITTED_FOR_AGENT`](/docs/building/verification/compliance-catalog#error-code-billing-not-permitted-for-agent) for the per-buyer-agent gate — so agents can dispatch on autonomous-retry vs human-onboarding without parsing prose. See [Buyer-agent identity](/docs/building/by-layer/L2/accounts-and-agents#buyer-agent-identity) and [Billing and Account Setup](/docs/building/by-layer/L3/error-handling#billing-and-account-setup). | | `billing_entity` | object | No | Structured business entity details for the party responsible for payment. Contains `legal_name` (required), plus optional `vat_id`, `tax_id`, `registration_number`, `address`, `contacts`, and `bank`. Bank details are write-only — included in requests but never echoed in responses. See [billing entity and invoice recipient](/docs/building/by-layer/L2/accounts-and-agents#billing-entity-and-invoice-recipient). | | `payment_terms` | string | No | Payment terms for this account: `net_15`, `net_30`, `net_45`, `net_60`, `net_90`, or `prepay`. The seller must either accept these terms or reject the account — terms are never silently remapped. When omitted, the seller applies its default terms. | | `sandbox` | boolean | No | When true, set up a sandbox account with no real platform calls or billing. Only applicable to buyer-declared accounts (`require_operator_auth: false`). For account-id namespaces, sandbox accounts are pre-existing test accounts discovered via [`list_accounts`](/docs/accounts/tasks/list_accounts) or supplied out-of-band. | | `notification_configs` | array | No | Account-level webhook subscribers for events that outlive any single media buy: creative lifecycle notifications, account status changes, and wholesale feed change webhooks. Omit to leave existing subscribers unchanged; send `[]` to remove all subscribers; send a full array to replace. Entries are keyed by account-scoped `subscriber_id`; an existing `subscriber_id` is upserted, and persisted IDs absent from the sent array are removed. | -**Natural key**: The tuple `(brand, operator, sandbox)` uniquely identifies an account relationship. `{brand: {domain: "acme-corp.com"}, operator: "acme-corp.com"}` (direct) is a different account from `{brand: {domain: "acme-corp.com"}, operator: "pinnacle-media.com"}` (via agency). Adding `sandbox: true` provisions a sandbox account for the same brand/operator pair — no real platform calls or billing. +**Natural key**: The tuple `(brand, operator, operator_region, sandbox)` uniquely identifies an account relationship; `brand.market` is nested within the brand portion. `{brand: {domain: "nova-athletics.example", market: "NL"}, operator: "nova-athletics.example", operator_region: "emea"}` identifies the NL brand market managed by the EMEA operator unit. Adding `sandbox: true` provisions its separate sandbox account — no real platform calls or billing. ## Response @@ -121,8 +122,9 @@ Returns an `accounts` array with per-account results. Individual accounts may be | Field | Description | |-------|-------------| -| `brand` | Echoed from request. Object with `domain` and optional `brand_id`. | +| `brand` | Echoed from request. Object with `domain` and optional `brand_id`/`market`. | | `operator` | Echoed from request. | +| `operator_region` | Echoed when supplied. | | `name` | Seller's display name for the account. | | `action` | What happened: `created`, `updated`, `unchanged`, or `failed`. | | `status` | Current state of the account (see [Account status](#account-status)). | diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index ba48f5071a..847e43be04 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -407,7 +407,7 @@ A daypart with no declared semantics is ambiguous and MUST be rejected with `INV The compact tools reuse legacy implementation paths where useful but are distinct logical operations. A retry MUST keep the same tool name and payload. Reusing a key on another tool is a different payload and returns `IDEMPOTENCY_CONFLICT`; callers must not switch names after an ambiguous failure. -`request_proposals` binds each opaque proposal ID to its full BrandKey (`domain` plus optional `brand_id`) and, when supplied, its seller account. `refine_proposals` and `decline_proposals` resolve that binding before idempotency lookup, require every resolvable ID in a batch to share one binding, and scope the key to the resolved account or BrandKey. Callers do not repeat identity data on those mutation requests, and sellers MUST return the same not-found response for an absent proposal and a proposal outside the caller's principal/account scope. When no ID in a batch resolves, sellers use a principal-local negative-lookup scope; this permits safe replay of an all-`unable` result without creating a cross-account oracle. +`request_proposals` binds each opaque proposal ID to its full BrandKey (`domain`, optional `brand_id`, and optional country `market`) and, when supplied, its seller account. A natural account's `operator_region` is part of that account binding, so two regional operator units serving the same brand market do not share an idempotency or proposal namespace. `refine_proposals` and `decline_proposals` resolve that binding before idempotency lookup, require every resolvable ID in a batch to share one binding, and scope the key to the resolved account or BrandKey. Callers do not repeat identity data on those mutation requests, and sellers MUST return the same not-found response for an absent proposal and a proposal outside the caller's principal/account scope. When no ID in a batch resolves, sellers use a principal-local negative-lookup scope; this permits safe replay of an all-`unable` result without creating a cross-account oracle. This section applies only to AdCP task requests. OpenRTB bid streams have their own semantics (`BidRequest.id` is a transaction ID, not an idempotency key) and are out of scope. diff --git a/docs/building/by-layer/L2/accounts-and-agents.mdx b/docs/building/by-layer/L2/accounts-and-agents.mdx index ea0a84e9a8..9b526281a8 100644 --- a/docs/building/by-layer/L2/accounts-and-agents.mdx +++ b/docs/building/by-layer/L2/accounts-and-agents.mdx @@ -8,14 +8,14 @@ AdCP distinguishes four entities in every billable operation: | Entity | Question | How identified | |--------|----------|----------------| -| **Brand** | Whose products are advertised? | Brand reference: `domain` + optional `brand_id` ([brand.json](/docs/brand-protocol/brand-json)) | +| **Brand** | Whose products are advertised? | Brand reference: `domain` + optional `brand_id` and country `market` ([brand.json](/docs/brand-protocol/brand-json)) | | **Account** | Who gets billed? What rates apply? | [Account reference](#account-references) | | **Operator** | Who operates on the brand's behalf? | Domain (e.g., `pinnacle-media.com`) | | **Agent** | What software is placing the buy? | Authenticated session | -**Brand** — The advertiser whose products or services are promoted. Identified by a `brand` reference (`domain` + optional `brand_id`), resolved via `/.well-known/brand.json`. Single-brand houses use the domain alone (no `brand_id`). +**Brand** — The advertiser whose products or services are promoted. Identified by a `brand` reference (`domain` + optional `brand_id` and ISO country `market`), resolved via `/.well-known/brand.json`. `market` distinguishes local-market identities without becoming delivery targeting. -**Account** — A billing relationship between a buyer and seller. Determines rate card, payment terms, credit limit, and who receives invoices. Every billable operation requires an account reference — a seller-assigned `account_id` when the seller or upstream platform owns the canonical account namespace, or a natural key (`brand`, `operator`) when that tuple is the durable protocol key for buyer-declared accounts. Sandbox accounts follow the same model — account-id namespaces use pre-existing sandbox IDs from `list_accounts` or out-of-band setup, while buyer-declared sandboxes use the natural key with `sandbox: true`. +**Account** — A billing relationship between a buyer and seller. Determines rate card, payment terms, credit limit, and who receives invoices. Every billable operation requires an account reference — a seller-assigned `account_id` when the seller or upstream platform owns the canonical account namespace, or a natural key (`brand`, `operator`, optional `operator_region`, `sandbox`) for buyer-declared accounts. **Operator** — The entity driving buys — an agency trading desk, the brand's internal team, or another entity acting on behalf of the advertiser. Identified by domain and verifiable via [authorized operators](#authorized-operators) in `brand.json`. @@ -385,7 +385,7 @@ Some sellers use `account_id` without exposing an account discovery surface. In The agent manages the buying relationship. It calls `sync_accounts` to tell the seller who's advertising, who's operating on the brand's behalf, and who's paying. The seller provisions accounts and responds with status — the account IDs are a byproduct of the declaration, not something the buyer needs to know upfront. -Buyer-declared-account sellers SHOULD also expose `list_accounts` as the recovery read for stateless buyers. Replaying `sync_accounts` is not a cold-start recovery mechanism when the buyer has lost the `(brand, operator, sandbox)` natural keys required to compose that replay. `list_accounts` returns the relationships the seller already acknowledged; it does not change the natural-key contract or make the seller-issued `account_id` mandatory on later calls. +Buyer-declared-account sellers SHOULD also expose `list_accounts` as the recovery read for stateless buyers. Replaying `sync_accounts` is not a cold-start recovery mechanism when the buyer has lost the `(brand, operator, operator_region, sandbox)` natural keys required to compose that replay. `list_accounts` returns the relationships the seller already acknowledged; it does not change the natural-key contract or make the seller-issued `account_id` mandatory on later calls. **Typical sellers:** Traditional publishers, retail media networks, DSPs — anywhere the buying relationship is established programmatically. @@ -393,8 +393,9 @@ Buyer-declared-account sellers SHOULD also expose `list_accounts` as the recover | Flag | What it tells the seller | |------|-------------------------| -| `brand` (`domain` + optional `brand_id`) | Which brand is advertising | +| `brand` (`domain` + optional `brand_id`/`market`) | Which brand and country market is advertising | | `operator` | Who operates on the brand's behalf (agency, trading desk, or the brand itself) | +| `operator_region` | Optional operator-defined business region | | `billing` | Who gets the invoice — `operator`, `agent`, or `advertiser` | | `billing_entity` | Structured business entity details for the party responsible for payment — legal name, VAT ID, tax ID, address, contacts, and bank details. Used for formal B2B invoicing. Bank details are write-only (never echoed in responses). | | `payment_terms` | Payment terms for this account (`net_15`, `net_30`, `net_45`, `net_60`, `net_90`, `prepay`). The seller must accept these terms or reject the account — terms are never silently remapped. | @@ -427,7 +428,7 @@ The agent may be directly responsible for billing when `billing` is `"agent"`. W ### Natural key semantics -The tuple `(brand, operator, sandbox)` uniquely identifies an account relationship. The `brand` is a nested object with `domain` and optional `brand_id`. `operator` is always required — when the brand operates directly, set `operator` to the brand's domain. `sandbox` defaults to `false` when omitted. For example, `{brand: {domain: "acme-corp.com"}, operator: "acme-corp.com"}` (brand buying direct) is a different account from `{brand: {domain: "acme-corp.com"}, operator: "pinnacle-media.com"}` (brand via agency). Adding `sandbox: true` references the sandbox account for the same pair. +The tuple `(brand, operator, operator_region, sandbox)` uniquely identifies an account relationship. The nested `brand` carries `domain`, optional `brand_id`, and optional ISO country `market`; `operator_region` is an optional operator-defined ID such as `emea`. Both qualifiers participate in identity and neither targets delivery. For example, `{brand: {domain: "nova-athletics.example", market: "NL"}, operator: "nova-athletics.example", operator_region: "emea"}` identifies the NL brand market under its EMEA operator unit. See [sync_accounts task reference](/docs/accounts/tasks/sync_accounts) for the full request/response schema. @@ -457,7 +458,7 @@ The agent does not choose the scope — the seller assigns it based on its own a When multiple natural keys resolve to the same scope, the `account_scope` explains why. -For buyer-declared accounts (`require_operator_auth: false`), use natural keys (`brand` + `operator`) on subsequent requests — adding `sandbox: true` for sandbox accounts. A seller may also return an `account_id` from `sync_accounts` as its internal handle, but the seller MUST continue accepting the natural-key `AccountRef` for every account provisioned this way. For account-id namespaces (`require_operator_auth: true`), discover account IDs via `list_accounts` for upstream-managed namespaces, or receive them out-of-band for seller-defined namespaces — including sandbox test accounts. +For buyer-declared accounts (`require_operator_auth: false`), use the complete natural key (`brand` + `operator` + optional `operator_region`) on subsequent requests — adding `sandbox: true` for sandbox accounts. A seller may also return an `account_id` from `sync_accounts` as its internal handle, but the seller MUST continue accepting the natural-key `AccountRef` for every account provisioned this way. For account-id namespaces (`require_operator_auth: true`), discover account IDs via `list_accounts` for upstream-managed namespaces, or receive them out-of-band for seller-defined namespaces — including sandbox test accounts. ## Error codes diff --git a/docs/media-buy/task-reference/request_proposals.mdx b/docs/media-buy/task-reference/request_proposals.mdx index 00e7d6149e..80e0be7041 100644 --- a/docs/media-buy/task-reference/request_proposals.mdx +++ b/docs/media-buy/task-reference/request_proposals.mdx @@ -7,7 +7,7 @@ testable: false `request_proposals` creates one or more immutable draft media-plan proposal snapshots from a brief. It can begin a consultative workflow directly or use `product_ids` returned by [`list_products`](/docs/media-buy/task-reference/list_products). Success always contains at least one proposal; returning products without a proposal does not satisfy this task. -`brand` contains only the stable BrandKey (`domain` plus optional `brand_id`); the seller resolves the canonical brand manifest rather than receiving a full brand file in every call. `account` is optional and adds seller-specific commercial terms. A natural-key account (`brand` plus `operator`) can supply the request's sole brand identity instead of repeating top-level `brand`. +`brand` contains only the stable BrandKey (`domain`, optional `brand_id`, and optional ISO country `market`); the seller resolves the canonical brand manifest rather than receiving a full brand file in every call. `market` qualifies identity and does not target delivery. `account` is optional and adds seller-specific commercial terms. A natural-key account can supply the request's sole brand identity and independently qualify the operator with `operator_region` — for example, an NL-market brand managed by the operator's EMEA business unit. **Request schema:** [`/schemas/v3/media-buy/request-proposals-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/request-proposals-request.json) @@ -15,7 +15,11 @@ testable: false { "idempotency_key": "550e8400-e29b-41d4-a716-446655441001", "brief": "Reach outdoor enthusiasts with premium video inventory.", - "brand": { "domain": "acmeoutdoor.example" }, + "account": { + "brand": { "domain": "nova-athletics.example", "market": "NL" }, + "operator": "nova-athletics.example", + "operator_region": "emea" + }, "opportunity": { "opportunity_id": "opp_spring_launch_2027", "phase": "active_sourcing", diff --git a/docs/protocol/calling-an-agent.mdx b/docs/protocol/calling-an-agent.mdx index 4d07993d08..74b5305a8f 100644 --- a/docs/protocol/calling-an-agent.mdx +++ b/docs/protocol/calling-an-agent.mdx @@ -46,7 +46,7 @@ Missing the key → `adcp_error.code: 'VALIDATION_ERROR'` with `/idempotency_key // buyer-declared sellers may also echo account_id from sync_accounts as an internal handle) "account": { "account_id": "seller_assigned_id" } -// variant 1: by natural key (brand + operator, optional sandbox) +// variant 1: by natural key (brand + operator, optional market/operator_region/sandbox) // brand.domain — the buyer's brand domain (e.g., advertiser website) // operator — the buyer-side entity operating on behalf of the brand "account": { "brand": { "domain": "acme.com" }, "operator": "pinnacle-media.com" } diff --git a/server/src/training-agent/account-handlers.ts b/server/src/training-agent/account-handlers.ts index 792ffa7482..9c83e03a6b 100644 --- a/server/src/training-agent/account-handlers.ts +++ b/server/src/training-agent/account-handlers.ts @@ -38,8 +38,9 @@ interface SyncAccountsInput extends ToolArgs { interface SyncAccountInput { account?: AccountRef; - brand?: { domain: string; brand_id?: string; name?: string }; + brand?: { domain: string; brand_id?: string; market?: string; name?: string }; operator?: string; + operator_region?: string; billing?: 'operator' | 'agent' | 'advertiser'; billing_entity?: Record; payment_terms?: string; @@ -49,8 +50,9 @@ interface SyncAccountInput { interface AccountState { accountId: string; - brand: { domain: string; brand_id?: string; name?: string }; + brand: { domain: string; brand_id?: string; market?: string; name?: string }; operator: string; + operatorRegion?: string; billing: string; billingEntity?: Record; paymentTerms: string; @@ -131,9 +133,14 @@ function getAccountMap(sessionKey: string, principal?: string): Map, ref: AccountRef): AccountState | undefined { @@ -144,7 +151,7 @@ function findAccountByRef(accounts: Map, ref: AccountRef): return undefined; } if (ref.brand?.domain && ref.operator) { - return accounts.get(accountKey(ref.brand, ref.operator)); + return accounts.get(accountKey(ref.brand, ref.operator, ref.operator_region, ref.sandbox === true)); } return undefined; } @@ -180,8 +187,10 @@ export function sandboxAccountRefForId( brand: { domain: account.brand.domain.toLowerCase(), ...(account.brand.brand_id && { brand_id: account.brand.brand_id }), + ...(account.brand.market && { market: account.brand.market }), }, operator: account.operator.toLowerCase(), + ...(account.operatorRegion && { operator_region: account.operatorRegion }), sandbox: true, }; } @@ -215,6 +224,7 @@ function accountStateFromWire(wire: AccountWireShape, now: string): AccountState accountId: wire.account_id, brand: wire.brand, operator: wire.operator, + operatorRegion: wire.operator_region, billing: wire.billing, paymentTerms: wire.payment_terms ?? 'net_30', status: wire.status, @@ -243,10 +253,11 @@ interface AccountWireShape { account_id: string; name: string; advertiser: string; - // brand-ref.json defines this object; it carries domain + optional brand_id + // brand-ref.json defines this object; it carries domain + optional brand_id/market // only — `name` is not in the schema and additionalProperties is false. - brand: { domain: string; brand_id?: string }; + brand: { domain: string; brand_id?: string; market?: string }; operator: string; + operator_region?: string; billing: string; account_scope: string; status: string; @@ -266,14 +277,16 @@ function accountStateToWire(account: AccountState): AccountWireShape { // so emit only the schema-declared fields. AccountState.brand.name is an // operational hint we use to derive `name`/`advertiser` above; it never // reaches the buyer. - const wireBrand: { domain: string; brand_id?: string } = { domain: account.brand.domain }; + const wireBrand: { domain: string; brand_id?: string; market?: string } = { domain: account.brand.domain }; if (account.brand.brand_id !== undefined) wireBrand.brand_id = account.brand.brand_id; + if (account.brand.market !== undefined) wireBrand.market = account.brand.market; const wire: AccountWireShape = { account_id: account.accountId, name: displayName, advertiser, brand: wireBrand, operator: account.operator, + ...(account.operatorRegion && { operator_region: account.operatorRegion }), billing: account.billing, account_scope: account.accountScope, status: account.status, @@ -320,7 +333,9 @@ function sanitizeNotificationConfigs(configs: NotificationConfigState[]): Array< function validationFailure(input: SyncAccountInput, field: string, message: string): Record { return { - ...(input.account ? { account: input.account } : { brand: input.brand, operator: input.operator }), + ...(input.account + ? { account: input.account } + : { brand: input.brand, operator: input.operator, ...(input.operator_region && { operator_region: input.operator_region }) }), action: 'failed', status: 'rejected', errors: [{ code: 'VALIDATION_ERROR', field, message }], @@ -515,7 +530,16 @@ export function getAccountNotificationSubscribers( for (const accounts of accountMaps) { for (const account of accounts.values()) { if (accountId && account.accountId !== accountId) continue; - if (!accountId && canUseNaturalKey && accountKey(accountRef!.brand!, accountRef!.operator!) !== accountKey(account.brand, account.operator)) continue; + if ( + !accountId + && canUseNaturalKey + && accountKey( + accountRef!.brand!, + accountRef!.operator!, + accountRef!.operator_region, + accountRef!.sandbox === true, + ) !== accountKey(account.brand, account.operator, account.operatorRegion, account.sandbox) + ) continue; for (const config of account.notificationConfigs) { if (!config.active || !config.eventTypes.includes(notificationType)) continue; const subscriberKey = `${account.accountId}\u001F${config.subscriberId}\u001F${notificationType}`; @@ -586,8 +610,9 @@ export function seedAccountFixture( } const fixture = (params.fixture ?? {}) as Record; - const brand = fixture.brand as { domain?: string; brand_id?: string; name?: string } | undefined; + const brand = fixture.brand as { domain?: string; brand_id?: string; market?: string; name?: string } | undefined; const operator = fixture.operator; + const operatorRegion = typeof fixture.operator_region === 'string' ? fixture.operator_region : undefined; if (!brand?.domain) { return { success: false, error: 'INVALID_PARAMS', error_detail: 'params.fixture.brand.domain is required for seed_account' }; } @@ -600,19 +625,26 @@ export function seedAccountFixture( const now = new Date().toISOString(); const sessionKey = sessionKeyFromArgs({}, ctx.mode, ctx.userId, ctx.moduleId); const accounts = getAccountMap(sessionKey, ctx.principal); - const key = accountKey(brand as { domain: string; brand_id?: string }, operator); + const sandbox = fixture.sandbox !== false; + const key = accountKey( + brand as { domain: string; brand_id?: string; market?: string }, + operator, + operatorRegion, + sandbox, + ); const existing = accounts.get(key) ?? findAccountByIdAcrossSessions(accountId, ctx.principal); const state: AccountState = { accountId, - brand: brand as { domain: string; brand_id?: string; name?: string }, + brand: brand as { domain: string; brand_id?: string; market?: string; name?: string }, operator, + operatorRegion, billing, paymentTerms: typeof fixture.payment_terms === 'string' ? fixture.payment_terms : 'net_30', status, accountScope: typeof fixture.account_scope === 'string' ? fixture.account_scope : 'operator_brand', - sandbox: fixture.sandbox !== false, + sandbox, rateCard: typeof fixture.rate_card === 'string' ? fixture.rate_card : 'sandbox', creditLimit: undefined, governanceAgents: [], @@ -695,11 +727,13 @@ export const ACCOUNT_REF_SCHEMA = { properties: { domain: { type: 'string' }, brand_id: { type: 'string' }, + market: { type: 'string', pattern: '^[A-Z]{2}$' }, }, required: ['domain'], additionalProperties: false, }, operator: { type: 'string' }, + operator_region: { type: 'string', minLength: 1, maxLength: 64, pattern: '^[a-z0-9][a-z0-9_-]*$' }, sandbox: { type: 'boolean' }, }, required: ['brand', 'operator'], @@ -759,12 +793,14 @@ export const ACCOUNT_TOOLS = [ properties: { domain: { type: 'string' }, brand_id: { type: 'string' }, + market: { type: 'string', pattern: '^[A-Z]{2}$' }, name: { type: 'string' }, }, required: ['domain'], }, account: ACCOUNT_REF_SCHEMA, operator: { type: 'string' }, + operator_region: { type: 'string', minLength: 1, maxLength: 64, pattern: '^[a-z0-9][a-z0-9_-]*$' }, billing: { type: 'string', enum: ['operator', 'agent', 'advertiser'] }, billing_entity: { type: 'object' }, payment_terms: { type: 'string', enum: ['net_15', 'net_30', 'net_45', 'net_60', 'net_90', 'prepay'] }, @@ -802,6 +838,7 @@ export const ACCOUNT_TOOLS = [ allOf: [ { not: { required: ['brand'] } }, { not: { required: ['operator'] } }, + { not: { required: ['operator_region'] } }, { not: { required: ['billing'] } }, ], }, @@ -911,6 +948,7 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { const mixedProvisioningFields = [ input.brand !== undefined && 'brand', input.operator !== undefined && 'operator', + input.operator_region !== undefined && 'operator_region', input.billing !== undefined && 'billing', input.sandbox !== undefined && 'sandbox', ].filter(Boolean); @@ -944,7 +982,12 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { continue; } if (!req.dry_run && !findAccountByRef(accounts, input.account)) { - accounts.set(accountKey(existing.brand, existing.operator), existing); + accounts.set(accountKey( + existing.brand, + existing.operator, + existing.operatorRegion, + existing.sandbox, + ), existing); } if (input.payment_terms && !SUPPORTED_PAYMENT_TERMS.includes(input.payment_terms)) { @@ -980,6 +1023,7 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { account: input.account, brand: existing.brand, operator: existing.operator, + ...(existing.operatorRegion && { operator_region: existing.operatorRegion }), action: 'updated', status: existing.status, billing: existing.billing, @@ -1124,7 +1168,7 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { continue; } - const key = accountKey(input.brand, input.operator); + const key = accountKey(input.brand, input.operator, input.operator_region, input.sandbox === true); const existing = accounts.get(key); const isSandbox = input.sandbox === true; const accountId = existing?.accountId || `acc_${input.brand.domain.replace(/\./g, '_')}_${randomUUID().slice(0, 8)}`; @@ -1143,6 +1187,7 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { results.push({ brand: input.brand, operator: input.operator, + ...(input.operator_region && { operator_region: input.operator_region }), action: existing ? 'updated' : 'created', status: isSandbox ? 'active' : 'pending_approval', billing: input.billing, @@ -1168,6 +1213,7 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { accountId, brand: input.brand, operator: input.operator, + operatorRegion: input.operator_region, billing: input.billing!, billingEntity: input.billing_entity, paymentTerms: input.payment_terms || 'net_30', @@ -1192,6 +1238,7 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { account_id: accountId, brand: input.brand, operator: input.operator, + ...(input.operator_region && { operator_region: input.operator_region }), name: `${input.brand.name || input.brand.domain} (via ${input.operator})`, action, status, @@ -1248,7 +1295,9 @@ function wireAccountMatchesRef(account: AccountWireShape, ref: AccountRef): bool if (!ref.brand?.domain || !ref.operator) return false; if (account.brand.domain !== ref.brand.domain) return false; if (ref.brand.brand_id !== undefined && account.brand.brand_id !== ref.brand.brand_id) return false; + if ((account.brand.market ?? undefined) !== (ref.brand.market ?? undefined)) return false; if (account.operator !== ref.operator) return false; + if ((account.operator_region ?? undefined) !== (ref.operator_region ?? undefined)) return false; if (typeof ref.sandbox === 'boolean') return (account.sandbox === true) === ref.sandbox; return true; } diff --git a/server/src/training-agent/account-scope.ts b/server/src/training-agent/account-scope.ts index 5cb44fcee6..c867521192 100644 --- a/server/src/training-agent/account-scope.ts +++ b/server/src/training-agent/account-scope.ts @@ -1,17 +1,21 @@ import type { AccountRef } from './types.js'; const ACCOUNT_ID_KEYS = new Set(['account_id']); -const NATURAL_ACCOUNT_KEYS = new Set(['brand', 'operator', 'sandbox']); +const NATURAL_ACCOUNT_KEYS = new Set(['brand', 'operator', 'operator_region', 'sandbox']); const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i; const BRAND_ID_RE = /^[a-z0-9_]+$/; +const MARKET_RE = /^[A-Z]{2}$/; +const OPERATOR_REGION_RE = /^[a-z0-9][a-z0-9_-]*$/; const MAX_DOMAIN_LENGTH = 253; +const MAX_OPERATOR_REGION_LENGTH = 64; export type CanonicalAccountRef = | { kind: 'account_id'; account_id: string } | { kind: 'natural'; - brand: { domain: string; brand_id?: string }; + brand: { domain: string; brand_id?: string; market?: string }; operator: string; + operator_region?: string; sandbox: boolean; }; @@ -93,6 +97,24 @@ export function canonicalizeAccountRef(value: unknown): CanonicalAccountRef { } brandId = value.brand.brand_id; } + let market: string | undefined; + if (Object.prototype.hasOwnProperty.call(value.brand, 'market')) { + if (typeof value.brand.market !== 'string' || !MARKET_RE.test(value.brand.market)) { + invalid('account.brand.market must be an ISO 3166-1 alpha-2 country code.'); + } + market = value.brand.market; + } + let operatorRegion: string | undefined; + if (Object.prototype.hasOwnProperty.call(value, 'operator_region')) { + if ( + typeof value.operator_region !== 'string' + || value.operator_region.length > MAX_OPERATOR_REGION_LENGTH + || !OPERATOR_REGION_RE.test(value.operator_region) + ) { + invalid('account.operator_region must be a lowercase operator-defined region identifier.'); + } + operatorRegion = value.operator_region; + } if (value.sandbox !== undefined && typeof value.sandbox !== 'boolean') { invalid('account.sandbox must be a boolean when provided.'); } @@ -102,8 +124,10 @@ export function canonicalizeAccountRef(value: unknown): CanonicalAccountRef { brand: { domain, ...(brandId !== undefined && { brand_id: brandId }), + ...(market !== undefined && { market }), }, operator, + ...(operatorRegion !== undefined && { operator_region: operatorRegion }), sandbox: value.sandbox ?? false, }; } @@ -112,11 +136,19 @@ export function canonicalizeAccountRef(value: unknown): CanonicalAccountRef { export function accountScopeFromRef(value: AccountRef | unknown): string { const account = canonicalizeAccountRef(value); if (account.kind === 'account_id') return `a:${account.account_id}`; - return [ + const base = [ 'n', account.brand.domain, account.brand.brand_id ?? '-', account.operator, account.sandbox ? '1' : '0', ].join(':'); + if (account.brand.market === undefined && account.operator_region === undefined) return base; + return [ + base, + 'm', + account.brand.market ?? '-', + 'r', + account.operator_region ?? '-', + ].join(':'); } diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 35a4b73130..abcca355b8 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -2375,11 +2375,13 @@ async function deriveProductDiscoveryAccountScope( const scopes = new Set(); for (const proposalId of proposalIds) { const internal = proposalsById.get(proposalId) as unknown as Record | undefined; - if (typeof internal?.__account_id === 'string') scopes.add(`a:${internal.__account_id}`); + if (typeof internal?.__account_scope === 'string') scopes.add(internal.__account_scope); + else if (typeof internal?.__account_id === 'string') scopes.add(`a:${internal.__account_id}`); else if (typeof internal?.__brand_domain === 'string') { scopes.add(compactBrandScope({ domain: internal.__brand_domain, ...(typeof internal.__brand_id === 'string' && { brand_id: internal.__brand_id }), + ...(typeof internal.__brand_market === 'string' && { market: internal.__brand_market }), })!); } } @@ -4208,7 +4210,8 @@ function isProductDiscoveryTool(toolName: string): boolean { function compactBrandScope(brand: unknown): string | undefined { if (!isRecord(brand) || typeof brand.domain !== 'string' || brand.domain.length === 0) return undefined; const brandId = typeof brand.brand_id === 'string' ? brand.brand_id : ''; - return `b:${brand.domain.toLowerCase()}#${brandId}`; + const market = typeof brand.market === 'string' ? brand.market : ''; + return `b:${brand.domain.toLowerCase()}#${brandId}${market ? `@${market}` : ''}`; } function productDiscoverySourceSchemaName(toolName: string): string | undefined { @@ -4398,7 +4401,7 @@ function proposalTermsDigest(commercialTerms: Record): string { function buildCanonicalCommercialTerms( proposal: Proposal, products: Map, - brand: { domain: string; brand_id?: string }, + brand: { domain: string; brand_id?: string; market?: string }, ): Record { const internal = proposal as unknown as Record; if (isRecord(internal.__canonical_commercial_terms)) { @@ -4449,7 +4452,7 @@ function buildCanonicalCommercialTerms( function withCanonicalProposalEnvelope( proposal: Proposal, products: Map, - brand: { domain: string; brand_id?: string }, + brand: { domain: string; brand_id?: string; market?: string }, options: { rebuild?: boolean } = {}, ): Proposal { const internal = { ...proposal } as unknown as Record; @@ -4477,6 +4480,7 @@ function outwardProposal(proposal: Record, products: Map; const requestAccount = isRecord(requestRecord.account) ? requestRecord.account : undefined; - const requestBrand = isRecord(requestRecord.brand) ? requestRecord.brand : undefined; + const accountBrand = isRecord(requestAccount?.brand) ? requestAccount.brand : undefined; + const requestBrand = isRecord(requestRecord.brand) ? requestRecord.brand : accountBrand; const requestOpportunity = isRecord(requestRecord.opportunity) ? requestRecord.opportunity : undefined; + const proposalAccountScope = requestAccount + ? accountScopeFromRef(requestAccount) + : compactBrandScope(requestBrand); const proposalOwner = JSON.stringify({ ...(typeof requestAccount?.account_id === 'string' && { account_id: requestAccount.account_id }), brand: { domain: typeof requestBrand?.domain === 'string' ? requestBrand.domain.toLowerCase() : '', ...(typeof requestBrand?.brand_id === 'string' && { brand_id: requestBrand.brand_id }), + ...(typeof requestBrand?.market === 'string' && { market: requestBrand.market }), }, + ...(typeof requestAccount?.operator === 'string' && { operator: requestAccount.operator.toLowerCase() }), + ...(typeof requestAccount?.operator_region === 'string' + && { operator_region: requestAccount.operator_region }), + ...(typeof requestAccount?.sandbox === 'boolean' && { sandbox: requestAccount.sandbox }), }); proposals = proposals.map((proposal, index) => { const digest = createHash('sha256') @@ -6317,7 +6330,9 @@ async function handleGetProductsUnlocked( proposal_id: proposalId, ...(typeof requestBrand?.domain === 'string' && { __brand_domain: requestBrand.domain.toLowerCase() }), ...(typeof requestBrand?.brand_id === 'string' && { __brand_id: requestBrand.brand_id }), + ...(typeof requestBrand?.market === 'string' && { __brand_market: requestBrand.market }), ...(typeof requestAccount?.account_id === 'string' && { __account_id: requestAccount.account_id }), + ...(proposalAccountScope && { __account_scope: proposalAccountScope }), ...(typeof requestOpportunity?.opportunity_id === 'string' && { __opportunity_id: requestOpportunity.opportunity_id }), } as unknown as Proposal; @@ -6330,6 +6345,7 @@ async function handleGetProductsUnlocked( ? requestBrand.domain.toLowerCase() : 'advertiser.example', ...(typeof requestBrand?.brand_id === 'string' && { brand_id: requestBrand.brand_id }), + ...(typeof requestBrand?.market === 'string' && { market: requestBrand.market }), }, ); }); @@ -6400,6 +6416,7 @@ async function handleGetProductsUnlocked( if (existingSuccessor) return [existingSuccessor]; const brandDomain = (proposal as unknown as Record).__brand_domain; const brandId = (proposal as unknown as Record).__brand_id; + const brandMarket = (proposal as unknown as Record).__brand_market; let successor = refinement?.action === 'finalize' ? executableProposalSnapshot( revision, @@ -6411,6 +6428,7 @@ async function handleGetProductsUnlocked( { domain: typeof brandDomain === 'string' ? brandDomain : 'advertiser.example', ...(typeof brandId === 'string' && { brand_id: brandId }), + ...(typeof brandMarket === 'string' && { market: brandMarket }), }, { rebuild: true }, ); @@ -7887,10 +7905,11 @@ async function handleCreateMediaBuyUnlocked(args: ToolArgs, ctx: TrainingContext if (proposal) { const internal = proposal as unknown as Record; const accountRef = req.account as unknown as { account_id?: string }; - const requestBrand = req.brand as unknown as { domain?: string; brand_id?: string }; + const requestBrand = req.brand as unknown as { domain?: string; brand_id?: string; market?: string }; const boundAccountId = internal.__account_id; const boundBrandDomain = internal.__brand_domain; const boundBrandId = internal.__brand_id; + const boundBrandMarket = internal.__brand_market; const hasCompactOwnerBinding = typeof boundAccountId === 'string' || typeof boundBrandDomain === 'string' || typeof boundBrandId === 'string'; @@ -7901,6 +7920,8 @@ async function handleCreateMediaBuyUnlocked(args: ToolArgs, ctx: TrainingContext requestBrand?.domain?.toLowerCase() === boundBrandDomain && (typeof requestBrand.brand_id === 'string' ? requestBrand.brand_id : undefined) === (typeof boundBrandId === 'string' ? boundBrandId : undefined) + && (typeof requestBrand.market === 'string' ? requestBrand.market : undefined) + === (typeof boundBrandMarket === 'string' ? boundBrandMarket : undefined) ); if (hasCompactOwnerBinding && (!accountMatches || !brandMatches)) proposal = undefined; } @@ -7921,6 +7942,7 @@ async function handleCreateMediaBuyUnlocked(args: ToolArgs, ctx: TrainingContext const internalProposal = proposal as unknown as Record; const compactProposal = typeof internalProposal.__brand_domain === 'string' || typeof internalProposal.__brand_id === 'string' + || typeof internalProposal.__brand_market === 'string' || typeof internalProposal.__account_id === 'string'; if (internalProposal.__declined === true) { return { diff --git a/server/src/training-agent/types.ts b/server/src/training-agent/types.ts index c57ee833fb..f87b371b32 100644 --- a/server/src/training-agent/types.ts +++ b/server/src/training-agent/types.ts @@ -416,13 +416,15 @@ export interface ToolArgs { account?: AccountRef; brand?: BrandRef } export interface AccountRef { account_id?: string; - brand?: { domain: string; brand_id?: string }; + brand?: { domain: string; brand_id?: string; market?: string }; operator?: string; + operator_region?: string; sandbox?: boolean; } export interface BrandRef { domain: string; + market?: string; name?: string; } diff --git a/server/tests/unit/account-handlers.test.ts b/server/tests/unit/account-handlers.test.ts index c6b7b1b9f6..c65327de6e 100644 --- a/server/tests/unit/account-handlers.test.ts +++ b/server/tests/unit/account-handlers.test.ts @@ -125,6 +125,36 @@ describe('sync_accounts', () => { expect(acct.setup).toBeUndefined(); }); + it('keeps brand markets and operator regions distinct in the natural key', async () => { + const { result } = await simulateCallTool(server, 'sync_accounts', { + accounts: [ + { + brand: { domain: 'nova-athletics.example', market: 'NL' }, + operator: 'nova-athletics.example', + operator_region: 'emea', + billing: 'operator', + sandbox: true, + }, + { + brand: { domain: 'nova-athletics.example', market: 'BE' }, + operator: 'nova-athletics.example', + operator_region: 'emea', + billing: 'operator', + sandbox: true, + }, + ], + }); + + const accounts = result.accounts as Record[]; + expect(accounts).toHaveLength(2); + expect(new Set(accounts.map(account => account.account_id)).size).toBe(2); + expect(accounts[0]).toMatchObject({ + brand: { domain: 'nova-athletics.example', market: 'NL' }, + operator: 'nova-athletics.example', + operator_region: 'emea', + }); + }); + it('non-sandbox account is pending_approval with setup URL', async () => { const { result } = await simulateCallTool(server, 'sync_accounts', { accounts: [{ @@ -915,7 +945,7 @@ describe('sync_governance', () => { const { result } = await simulateCallTool(server, 'sync_governance', { accounts: [{ - account: { brand: { domain: 'acme.com' }, operator: 'agency-one' }, + account: { brand: { domain: 'acme.com' }, operator: 'agency-one', sandbox: true }, governance_agents: [{ url: 'https://governance.example.com/mcp', authentication: { schemes: ['bearer'], credentials: 'tok_123' }, @@ -935,7 +965,7 @@ describe('sync_governance', () => { it('replaces the governance agent on second call', async () => { await createSandboxAccount(); - const ref = { brand: { domain: 'acme.com' }, operator: 'agency-one' }; + const ref = { brand: { domain: 'acme.com' }, operator: 'agency-one', sandbox: true }; // First sync — one agent await simulateCallTool(server, 'sync_governance', { diff --git a/server/tests/unit/training-agent-account-scope.test.ts b/server/tests/unit/training-agent-account-scope.test.ts index ff63134f32..74f54b3740 100644 --- a/server/tests/unit/training-agent-account-scope.test.ts +++ b/server/tests/unit/training-agent-account-scope.test.ts @@ -39,8 +39,27 @@ describe('canonical AccountRef scope', () => { accountScopeFromRef({ ...base, operator: 'two.example' }), accountScopeFromRef({ ...base, sandbox: true }), accountScopeFromRef({ ...base, brand: { ...base.brand, brand_id: 'spark' } }), + accountScopeFromRef({ ...base, brand: { ...base.brand, market: 'NL' } }), + accountScopeFromRef({ ...base, operator_region: 'emea' }), ]); - expect(scopes.size).toBe(4); + expect(scopes.size).toBe(6); + }); + + it('represents a country-market brand under a regional operator', () => { + const account = { + brand: { domain: 'Nova-Athletics.Example', market: 'NL' }, + operator: 'Nova-Athletics.Example', + operator_region: 'emea', + }; + expect(canonicalizeAccountRef(account)).toEqual({ + kind: 'natural', + brand: { domain: 'nova-athletics.example', market: 'NL' }, + operator: 'nova-athletics.example', + operator_region: 'emea', + sandbox: false, + }); + expect(accountScopeFromRef(account)) + .toBe('n:nova-athletics.example:-:nova-athletics.example:0:m:NL:r:emea'); }); it.each([ @@ -49,6 +68,8 @@ describe('canonical AccountRef scope', () => { [{ account_id: 'acct_123', sandbox: false }, 'exactly one identity'], [{ brand: { domain: 'house.example' } }, 'exactly one identity'], [{ brand: { domain: 'house.example' }, operator: 'one.example', unexpected: true }, "field 'unexpected'"], + [{ brand: { domain: 'house.example', market: 'nl' }, operator: 'one.example' }, 'ISO 3166-1'], + [{ brand: { domain: 'house.example' }, operator: 'one.example', operator_region: 'EMEA' }, 'lowercase'], [{ account_id: 'acct_123', unexpected: true }, "field 'unexpected'"], ])('rejects invalid closed-union shape %#', (value, message) => { expect(() => canonicalizeAccountRef(value)).toThrow(AccountRefValidationError); diff --git a/static/schemas/source/account/sync-accounts-request.json b/static/schemas/source/account/sync-accounts-request.json index 71416c2d4c..09eb834534 100644 --- a/static/schemas/source/account/sync-accounts-request.json +++ b/static/schemas/source/account/sync-accounts-request.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/account/sync-accounts-request.json", "title": "Sync Accounts Request", - "description": "Sync advertiser account state with a seller. Two modes, distinguished by the key on each per-account entry:\n\n- **Provisioning mode** (`brand` + `operator` + `billing` at the entry root): the agent declares which brands it represents, who operates on each brand's behalf, and the billing model. The seller provisions or links accounts via upsert. Used when brand + operator (+ sandbox) is the durable protocol key for buyer-declared accounts (`require_operator_auth: false`). Sellers MAY echo a seller-assigned account_id, but they MUST continue accepting the natural-key AccountRef for every account provisioned this way.\n\n- **Settings-update mode** (`account` field carrying an [`AccountRef`](/schemas/core/account-ref.json)): targets an existing account by `account_id` (or by natural key for buyer-declared accounts). The seller updates the account's settable state (notification subscriptions, payment terms, billing entity refinements) — no provisioning side effects. Used for account_id namespaces only when the seller exposes this task for account settings; the account_id itself is discovered via `list_accounts` for upstream-managed namespaces or supplied out-of-band for seller-defined namespaces. Buyer-declared account sellers MAY also accept this mode for settings updates against accounts they previously provisioned.\n\nExactly one of the two key shapes is allowed per entry. Sellers that do not implement settings-update mode MUST return `UNSUPPORTED_PROVISIONING` on entries keyed by `account.account_id`; sellers that do not provision through sync_accounts MUST return `UNSUPPORTED_PROVISIONING` on entries keyed by the natural-key trio. Account-id namespace provisioning is out of scope unless a future explicit capability declares it.", + "description": "Sync advertiser account state with a seller. Two modes, distinguished by the key on each per-account entry:\n\n- **Provisioning mode** (`brand` + `operator` + `billing` at the entry root): the agent declares which brands it represents, who operates on each brand's behalf, and the billing model. Optional `brand.market` and `operator_region` qualify those identities. The seller provisions or links accounts via upsert. Used when brand + operator + optional operator_region (+ sandbox) is the durable protocol key for buyer-declared accounts (`require_operator_auth: false`). Sellers MAY echo a seller-assigned account_id, but they MUST continue accepting the complete natural-key AccountRef for every account provisioned this way.\n\n- **Settings-update mode** (`account` field carrying an [`AccountRef`](/schemas/core/account-ref.json)): targets an existing account by `account_id` (or by natural key for buyer-declared accounts). The seller updates the account's settable state (notification subscriptions, payment terms, billing entity refinements) — no provisioning side effects. Used for account_id namespaces only when the seller exposes this task for account settings; the account_id itself is discovered via `list_accounts` for upstream-managed namespaces or supplied out-of-band for seller-defined namespaces. Buyer-declared account sellers MAY also accept this mode for settings updates against accounts they previously provisioned.\n\nExactly one of the two key shapes is allowed per entry. Sellers that do not implement settings-update mode MUST return `UNSUPPORTED_PROVISIONING` on entries keyed by `account.account_id`; sellers that do not provision through sync_accounts MUST return `UNSUPPORTED_PROVISIONING` on entries keyed by the natural-key fields. Account-id namespace provisioning is out of scope unless a future explicit capability declares it.", "type": "object", "allOf": [ { @@ -13,7 +13,7 @@ "properties": { "idempotency_key": { "type": "string", - "description": "Client-generated unique key for at-most-once execution. Natural per-account upsert keys (brand, operator) handle resource-level dedup, but the envelope triggers onboarding webhooks, billing setup, and audit events — this key prevents those side effects from firing twice on retry. MUST be unique per (seller, request) pair. Use a fresh UUID v4 for each request.", + "description": "Client-generated unique key for at-most-once execution. Natural per-account upsert keys (brand, operator, optional operator_region, sandbox) handle resource-level dedup, but the envelope triggers onboarding webhooks, billing setup, and audit events — this key prevents those side effects from firing twice on retry. MUST be unique per (seller, request) pair. Use a fresh UUID v4 for each request.", "minLength": 16, "maxLength": 255, "pattern": "^[A-Za-z0-9_.:-]{16,255}$" @@ -38,6 +38,13 @@ "description": "Domain of the entity operating on the brand's behalf (e.g., 'pinnacle-media.com'). When the brand operates directly, this is the brand's domain. Verified against the brand's authorized_operators in brand.json. Required for **provisioning mode**; MUST be absent in settings-update mode.", "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" }, + "operator_region": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9_-]*$", + "description": "Optional operator-defined business region for provisioning mode. It becomes part of the natural account key. MUST be absent in settings-update mode." + }, "billing": { "$ref": "/schemas/enums/billing-party.json", "description": "Who should be invoiced. Required for **provisioning mode**; MUST be absent in settings-update mode (billing is fixed at provisioning time and cannot be changed via settings-update)." @@ -123,6 +130,13 @@ ] } }, + { + "not": { + "required": [ + "operator_region" + ] + } + }, { "not": { "required": [ diff --git a/static/schemas/source/core/account-ref.json b/static/schemas/source/core/account-ref.json index 2df29b02b1..782bc71ebf 100644 --- a/static/schemas/source/core/account-ref.json +++ b/static/schemas/source/core/account-ref.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/account-ref.json", "title": "Account Reference", - "description": "Reference to an account by seller-assigned ID or natural key. Use account_id when the seller or upstream platform owns the canonical account namespace: either a seller-defined account supplied out-of-band, or an upstream-managed namespace discovered with list_accounts before account-scoped calls. If a credential may access more than one account, the seller MUST expose list_accounts; if a credential is bound to exactly one account, the seller SHOULD expose list_accounts returning that singleton and MAY omit it only when the same explicit account_id is provided through another declared path or out-of-band onboarding. Use the natural key (brand + operator) when brand + operator (+ sandbox) is the durable protocol key for buyer-declared accounts provisioned through sync_accounts (require_operator_auth: false). For sandbox: account_id namespaces use pre-existing test accounts discovered via list_accounts or supplied out-of-band; buyer-declared accounts use the natural key with sandbox: true.", + "description": "Reference to an account by seller-assigned ID or natural key. Use account_id when the seller or upstream platform owns the canonical account namespace: either a seller-defined account supplied out-of-band, or an upstream-managed namespace discovered with list_accounts before account-scoped calls. If a credential may access more than one account, the seller MUST expose list_accounts; if a credential is bound to exactly one account, the seller SHOULD expose list_accounts returning that singleton and MAY omit it only when the same explicit account_id is provided through another declared path or out-of-band onboarding. Use the natural key (brand + operator + optional operator_region + sandbox) for buyer-declared accounts provisioned through sync_accounts (require_operator_auth: false). brand.market independently qualifies the advertiser's country market. For sandbox: account_id namespaces use pre-existing test accounts discovered via list_accounts or supplied out-of-band; buyer-declared accounts use the natural key with sandbox: true.", "type": "object", "oneOf": [ { @@ -28,6 +28,13 @@ "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$", "x-entity": "operator" }, + "operator_region": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9_-]*$", + "description": "Stable operator-defined business-region identifier. Omit when the operator does not split accounts regionally." + }, "sandbox": { "type": "boolean", "description": "When true, references the sandbox account for this brand/operator pair. Defaults to false (production account).", @@ -42,6 +49,7 @@ { "account_id": "acc_acme_001" }, { "brand": { "domain": "acme-corp.com" }, "operator": "acme-corp.com" }, { "brand": { "domain": "nova-brands.com", "brand_id": "spark" }, "operator": "pinnacle-media.com" }, + { "brand": { "domain": "nova-athletics.example", "market": "NL" }, "operator": "nova-athletics.example", "operator_region": "emea" }, { "brand": { "domain": "acme-corp.com" }, "operator": "acme-corp.com", "sandbox": true } ] } diff --git a/static/schemas/source/core/account.json b/static/schemas/source/core/account.json index e71db0b6eb..b262d556d7 100644 --- a/static/schemas/source/core/account.json +++ b/static/schemas/source/core/account.json @@ -36,6 +36,13 @@ "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$", "x-entity": "operator" }, + "operator_region": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9_-]*$", + "description": "Operator-defined business region that qualifies this account's operator identity. Omit when the operator does not split accounts regionally." + }, "billing": { "$ref": "/schemas/enums/billing-party.json", "description": "Who is invoiced on this account. See billing_entity for the invoiced party's business details." diff --git a/static/schemas/source/core/brand-key.json b/static/schemas/source/core/brand-key.json index e4ea060a00..970a6c5e7a 100644 --- a/static/schemas/source/core/brand-key.json +++ b/static/schemas/source/core/brand-key.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/brand-key.json", "title": "Brand Key", - "description": "Stable brand identity used to resolve the canonical brand manifest. The manifest URL is derived from domain; callers do not send brand assets or per-call brand overrides.", + "description": "Stable brand identity used to resolve the canonical brand manifest, optionally scoped to one country market. The manifest URL is derived from domain; callers do not send brand assets or per-call brand overrides.", "type": "object", "properties": { "domain": { @@ -13,6 +13,11 @@ "brand_id": { "$ref": "/schemas/core/brand-id.json", "description": "Brand within a house-of-brands manifest. Omit for a single-brand domain." + }, + "market": { + "type": "string", + "pattern": "^[A-Z]{2}$", + "description": "ISO 3166-1 alpha-2 country market for this brand identity (for example, NL). Omit when the identity is global or the house does not split the brand by market. This qualifies account and proposal identity; it is not a delivery-targeting instruction." } }, "required": ["domain"], diff --git a/static/schemas/source/core/brand-ref.json b/static/schemas/source/core/brand-ref.json index a56e2440d1..a9598be5ed 100644 --- a/static/schemas/source/core/brand-ref.json +++ b/static/schemas/source/core/brand-ref.json @@ -14,6 +14,11 @@ "$ref": "/schemas/core/brand-id.json", "description": "Brand identifier within the house portfolio. Optional for single-brand domains." }, + "market": { + "type": "string", + "pattern": "^[A-Z]{2}$", + "description": "ISO 3166-1 alpha-2 country market for this brand identity. Omit for a global/default identity. This qualifies account identity and is not a delivery-targeting instruction." + }, "industries": { "type": "array", "items": { "type": "string" }, @@ -76,6 +81,10 @@ }, { "domain": "acme-corp.com" + }, + { + "domain": "nova-athletics.example", + "market": "NL" } ] } diff --git a/static/schemas/source/core/canonical-account-ref.json b/static/schemas/source/core/canonical-account-ref.json index dfefc08b12..f552293024 100644 --- a/static/schemas/source/core/canonical-account-ref.json +++ b/static/schemas/source/core/canonical-account-ref.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/canonical-account-ref.json", "title": "Canonical Account Reference", - "description": "Compact account identity for canonical 3.2 tools. Uses either a seller-assigned account_id or the buyer-declared natural key without BrandRef inline overrides.", + "description": "Compact account identity for canonical 3.2 tools. Uses either a seller-assigned account_id or the buyer-declared natural key without BrandRef inline overrides. The natural key is (brand, operator, operator_region, sandbox): brand.market identifies the advertiser's country market, while operator_region identifies the operator's business region.", "type": "object", "anyOf": [ { @@ -26,6 +26,13 @@ "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$", "x-entity": "operator" }, + "operator_region": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9_-]*$", + "description": "Stable operator-defined business-region identifier (for example, emea, apac, or benelux). Omit when the operator does not split accounts regionally. This is part of the natural account key and is not a delivery-targeting instruction." + }, "sandbox": { "type": "boolean", "default": false @@ -34,5 +41,13 @@ "required": ["brand", "operator"], "additionalProperties": false } + ], + "examples": [ + { "account_id": "acc_nova_nl" }, + { + "brand": { "domain": "nova-athletics.example", "market": "NL" }, + "operator": "nova-athletics.example", + "operator_region": "emea" + } ] } diff --git a/static/schemas/source/media-buy/list-products-request.json b/static/schemas/source/media-buy/list-products-request.json index 1c4a930709..e627b998e9 100644 --- a/static/schemas/source/media-buy/list-products-request.json +++ b/static/schemas/source/media-buy/list-products-request.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/list-products-request.json", "title": "List Products Request", - "description": "Read seller offers with structured discovery criteria. Each invocation completes synchronously, while durable wholesale product-feed webhooks registered through sync_accounts keep buyer mirrors current without polling. list_products is the authoritative bootstrap and repair read for those subscriptions; proposal creation is handled by request_proposals.", + "description": "Read seller offers synchronously using structured criteria. Product-feed webhooks registered through sync_accounts keep buyer mirrors current; use list_products to bootstrap or repair a mirror, not to poll it. Use request_proposals for proposals.", "type": "object", "x-operation-family": "list_products", "x-added-in": "3.2.0", @@ -26,7 +26,7 @@ "push_notification_config": { "allOf": [{ "$ref": "/schemas/core/push-notification-config.json" }], "x-adcp-schema-uri": "/schemas/core/push-notification-config.json", - "description": "Uniform per-call envelope field accepted for SDK compatibility. This does not register a wholesale feed subscription; durable product.* and wholesale_feed.bulk_change subscribers are registered through sync_accounts notification_configs." + "description": "Per-call SDK envelope field. Register durable product.* and wholesale_feed.bulk_change subscriptions through sync_accounts notification_configs." }, "account": { "allOf": [{ "$ref": "/schemas/core/canonical-account-ref.json" }], @@ -39,7 +39,7 @@ "max_results": { "type": "integer", "minimum": 1, "maximum": 100, "default": 25 }, "if_feed_version": { "type": "string", - "description": "Opaque feed version returned by a prior list_products response or wholesale product-feed webhook for the same cache scope and canonicalized selection. Used for repair and conditional reconciliation, not routine polling when webhooks are active." + "description": "Feed version from list_products or its webhooks for the same cache scope and selection. Use for repair and conditional reconciliation, not webhook-backed polling." }, "if_pricing_version": { "type": "string", diff --git a/tests/composed-schema-validation.test.cjs b/tests/composed-schema-validation.test.cjs index 0f9b747f8a..b5dbb91aa0 100644 --- a/tests/composed-schema-validation.test.cjs +++ b/tests/composed-schema-validation.test.cjs @@ -3067,13 +3067,40 @@ async function runTests() { { idempotency_key: 'request-proposals-natural-account-0001', account: { - brand: { domain: 'acmeoutdoor.example' }, + brand: { domain: 'acmeoutdoor.example', market: 'NL' }, operator: 'buyer.example', + operator_region: 'emea', sandbox: true }, brief: 'Reach streaming audio listeners in Rome' }, - 'request_proposals accepts a natural-key account as the single brand source' + 'request_proposals accepts market and operator-region qualifiers in its natural account key' + ); + await testSchemaRejection( + '/schemas/media-buy/request-proposals-request.json', + { + idempotency_key: 'request-proposals-invalid-market-0001', + account: { + brand: { domain: 'acmeoutdoor.example', market: 'nl' }, + operator: 'buyer.example', + operator_region: 'EMEA' + }, + brief: 'Reach streaming audio listeners in Rome' + }, + 'request_proposals rejects non-canonical market and operator-region identifiers' + ); + await testSchemaValidation( + '/schemas/account/sync-accounts-request.json', + { + idempotency_key: 'sync-accounts-regional-0001', + accounts: [{ + brand: { domain: 'nova-athletics.example', market: 'NL' }, + operator: 'nova-athletics.example', + operator_region: 'emea', + billing: 'operator' + }] + }, + 'sync_accounts provisions the same market and operator-region natural key used by compact tools' ); await testSchemaRejection( '/schemas/media-buy/request-proposals-request.json', From d5f5c3d7cc6fab7ea4e8ff5a8daf64bf57c5b2ff Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 13 Aug 2026 12:07:34 +0200 Subject: [PATCH 43/49] test: preserve sandbox account identity in webhook flows --- .../training-agent-webhooks.test.ts | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/server/tests/integration/training-agent-webhooks.test.ts b/server/tests/integration/training-agent-webhooks.test.ts index e27918a518..73ec45a70f 100644 --- a/server/tests/integration/training-agent-webhooks.test.ts +++ b/server/tests/integration/training-agent-webhooks.test.ts @@ -760,7 +760,11 @@ describe('Training Agent webhook emission', () => { return response; }; - const account = { brand: { domain: 'creative-lifecycle.example' }, operator: 'pinnacle-agency.example' }; + const account = { + brand: { domain: 'creative-lifecycle.example' }, + operator: 'pinnacle-agency.example', + sandbox: true, + }; await callTool(10, 'sync_accounts', { idempotency_key: randomUUID(), @@ -768,7 +772,7 @@ describe('Training Agent webhook emission', () => { brand: account.brand, operator: account.operator, billing: 'operator', - sandbox: true, + sandbox: account.sandbox, notification_configs: [{ subscriber_id: 'buyer-primary', url: webhookUrl, @@ -798,7 +802,7 @@ describe('Training Agent webhook emission', () => { }); await callTool(12, 'comply_test_controller', { - account: { ...account, sandbox: true }, + account, brand: account.brand, scenario: 'force_creative_status', params: { @@ -898,12 +902,14 @@ describe('Training Agent webhook emission', () => { }; const brand = { domain: 'creative-lifecycle-scoped.example' }; + const ownerAccount = { brand, operator: 'agency-one.example', sandbox: true }; + const otherAccount = { brand, operator: 'agency-two.example', sandbox: true }; await callTool(20, 'sync_accounts', { idempotency_key: randomUUID(), accounts: [ { brand, - operator: 'agency-one.example', + operator: ownerAccount.operator, billing: 'operator', sandbox: true, notification_configs: [{ @@ -915,7 +921,7 @@ describe('Training Agent webhook emission', () => { }, { brand, - operator: 'agency-two.example', + operator: otherAccount.operator, billing: 'operator', sandbox: true, notification_configs: [{ @@ -930,7 +936,7 @@ describe('Training Agent webhook emission', () => { await callTool(21, 'sync_creatives', { idempotency_key: randomUUID(), - account: { brand, operator: 'agency-one.example' }, + account: ownerAccount, creatives: [{ creative_id: 'creative_lifecycle_scoped_test', name: 'Creative lifecycle scoped test', @@ -940,7 +946,7 @@ describe('Training Agent webhook emission', () => { }); await callTool(22, 'comply_test_controller', { - account: { brand, operator: 'agency-one.example', sandbox: true }, + account: ownerAccount, brand, scenario: 'force_creative_status', params: { @@ -955,7 +961,7 @@ describe('Training Agent webhook emission', () => { expect(body.subscriber_id).toBe('owner'); const ownerList = structuredToolResult(await callTool(23, 'list_creatives', { - account: { brand, operator: 'agency-one.example' }, + account: ownerAccount, creative_ids: ['creative_lifecycle_scoped_test'], include_webhook_activity: true, })); @@ -963,14 +969,14 @@ describe('Training Agent webhook emission', () => { expect(ownerCreative.webhook_activity).toHaveLength(1); const otherList = structuredToolResult(await callTool(24, 'list_creatives', { - account: { brand, operator: 'agency-two.example' }, + account: otherAccount, creative_ids: ['creative_lifecycle_scoped_test'], include_webhook_activity: true, })); expect(otherList.creatives).toEqual([]); const crossPrincipalList = structuredToolResult(await callTool(25, 'list_creatives', { - account: { brand, operator: 'agency-one.example' }, + account: ownerAccount, creative_ids: ['creative_lifecycle_scoped_test'], include_webhook_activity: true, }, OTHER_BILLABLE_AUTH)); From acfe8413ed0a6521e85e2a589b4253749d177bf1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 13 Aug 2026 13:24:32 +0200 Subject: [PATCH 44/49] fix(schema): extend MULTI_FINALIZE_UNSUPPORTED to cover refine_proposals call site Adds refine_proposals refinements[] as a documented call site alongside get_products refine[] in both enumDescriptions (narrative) and enumMetadata.suggestion (SDK recovery hint). The refine_proposals response schema already enforces the atomicity invariant ("if any result is finalized, every result MUST be finalized"), so a seller that cannot guarantee atomic cross-ad-server commit on refine_proposals has no catalog-sanctioned error code to return without this change. Co-Authored-By: Claude Sonnet 4.6 --- static/schemas/source/enums/error-code.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/static/schemas/source/enums/error-code.json b/static/schemas/source/enums/error-code.json index 607211b992..2e12c9e7d0 100644 --- a/static/schemas/source/enums/error-code.json +++ b/static/schemas/source/enums/error-code.json @@ -158,7 +158,7 @@ "PRODUCT_EXPIRED": "One or more referenced products have passed their expires_at timestamp and are no longer available for purchase. Recovery: correctable (re-discover with get_products to find current inventory).", "PROPOSAL_NOT_COMMITTED": "The referenced proposal has proposal_status 'draft' and cannot be accepted into a media buy. Recovery: correctable (finalize it through refine_proposals, or through the legacy get_products refine action).", "PROPOSAL_NOT_FOUND": "The referenced proposal_id is not recognized by the seller — it belongs to a different tenant, was never issued, or was evicted from the seller's session cache before consumption. Distinct from `PROPOSAL_EXPIRED` (a known proposal whose `expires_at` window has passed) and `PROPOSAL_NOT_COMMITTED` (a known proposal still in `draft`). Recovery: correctable (request a fresh proposal, finalize it, then retry accept_proposal; 3.x compatibility callers retry create_media_buy).", - "MULTI_FINALIZE_UNSUPPORTED": "Returned by sellers that cannot guarantee atomic commit across multiple proposals in a single `get_products` call (multiple `action: 'finalize'` entries in `refine[]` targeting different `proposal_id` values). The buyer's intent — atomic multi-proposal finalize — is structurally well-formed and per spec atomic, but this seller's downstream stack cannot satisfy the atomicity guarantee (e.g., the proposals route to two different ad servers with no 2PC). More specific than `INVALID_REQUEST` so buyers can distinguish 'this seller doesn't support multi-finalize' from 'the request itself is malformed'. Recovery: correctable (sequence the finalize calls one-`proposal_id`-per-call; there is no capability flag for multi-finalize support, so a successful first-attempt is the only positive discovery signal). See [refinement guide § Finalize is exclusive](/docs/media-buy/product-discovery/refinement#finalize-is-exclusive-within-refine).", + "MULTI_FINALIZE_UNSUPPORTED": "Returned by sellers that cannot guarantee atomic commit across multiple proposals in a single finalize batch. Two call sites where this applies: (1) a `get_products` call with multiple `action: 'finalize'` entries in `refine[]` targeting different `proposal_id` values; (2) a `refine_proposals` call with multiple `action: 'finalize'` entries in `refinements[]` targeting different `proposal_id` values. The buyer's intent — atomic multi-proposal finalize — is structurally well-formed and per spec atomic on both surfaces, but this seller's downstream stack cannot satisfy the atomicity guarantee (e.g., the proposals route to two different ad servers with no 2PC). More specific than `INVALID_REQUEST` so buyers can distinguish 'this seller doesn't support multi-finalize' from 'the request itself is malformed'. Recovery: correctable (sequence the finalize calls one-`proposal_id`-per-call on whichever task surface the buyer is using; there is no capability flag for multi-finalize support, so a successful first-attempt is the only positive discovery signal). See [refinement guide § Finalize is exclusive](/docs/media-buy/product-discovery/refinement#finalize-is-exclusive-within-refine).", "IO_REQUIRED": "The committed proposal requires a signed insertion order but no io_acceptance was provided. Recovery: correctable (review the proposal's insertion_order, accept terms, and include io_acceptance on accept_proposal; 3.x compatibility callers use create_media_buy).", "TERMS_REJECTED": "Buyer-proposed measurement_terms were rejected by the seller. The error details SHOULD identify which specific term was rejected and the seller's acceptable range or supported vendors. Recovery: correctable (adjust the proposed terms and retry, or omit measurement_terms to accept the product's defaults).", "BIDDING_PLACEMENT_CONFLICT": "The authored media-buy/package bidding scopes cannot be represented by the provider's native campaign, package, or shared-strategy placement rules. Sellers MUST detect this before any provider mutation and SHOULD identify the conflicting scopes and provider constraint in error.details. Recovery: correctable (remove or align package overrides, move the policy to the supported scope, or choose a compatible budget mode/product combination).", @@ -185,7 +185,7 @@ "BRAND_REQUIRED": "A billable operation was attempted without a brand reference. Every billable operation requires either a seller-assigned `account_id` or a natural key including `brand`. Recovery: correctable (include `brand` — `domain` plus optional `brand_id` — on the request).", "AGENT_SUSPENDED": "The calling buyer agent's commercial relationship with the seller is temporarily paused — the agent is onboarded but currently suspended. Sibling to `ACCOUNT_SUSPENDED` (account-wide) and `CAMPAIGN_SUSPENDED` (per-plan) but scoped to the agent-relationship axis (orthogonal to any specific account on that agent). The code itself is the discriminator — it does NOT carry an `error.details` payload (mirroring `BILLING_NOT_PERMITTED_FOR_AGENT`'s discriminator-by-code pattern), and MUST NOT carry per-agent commercial state (rate cards, payment terms, credit limit, billing entity, contact channels) since full disclosure of per-agent state in a single probe is a per-agent oracle. Cross-tenant onboarding oracle clamp + channel-coverage requirements (response shape, HTTP/A2A/MCP status, headers, side effects, observability, latency parity, retry-counter side channel) are normative in error-handling.mdx Per-Agent Authorization Gate; this description does not restate them to avoid drift. Recovery: terminal (re-onboarding may resolve the suspension; the agent MUST surface to a human at the buyer rather than auto-retrying — the agent cannot unilaterally lift a suspension, and re-attempts only reinforce the gate).", "AGENT_BLOCKED": "The calling buyer agent's commercial relationship with the seller is permanently denied — the agent is blocked. Sibling to `AGENT_SUSPENDED` on the agent-relationship axis but with no recovery path (a suspension may lift via re-onboarding; a block does not). The code itself is the discriminator — same posture as `AGENT_SUSPENDED`: no `error.details` payload, no per-agent commercial state, cross-tenant onboarding oracle clamp + channel-coverage requirements normative in error-handling.mdx Per-Agent Authorization Gate. Recovery: terminal (no autonomous recovery — the agent MUST surface to a human at the buyer; relationships are reinstated only through offline operator action with the seller, not via any seller-callable AdCP task).", - "CREDENTIAL_IN_ARGS": "The seller detected authentication material or caller-supplied trust material placed in request args (top-level, in `context`, in `ext`, or any other nested location in the task payload) instead of arriving on the relevant transport authentication or trust channel. This includes buyer-principal credentials that should arrive on the inbound transport (`Authorization: Bearer` per RFC 6750 §2 for HTTP, RFC 9421 signature headers for signed requests, MCP/A2A authentication framing per RFC 9728 §3), and evaluator-call credentials or JWK/JWKS/JWKS-URI trust material smuggled into evaluator-related payload fields instead of being established through the creative agent's outbound transport authentication to the evaluator. Distinct from `AUTH_REQUIRED` (no credentials presented or presented credentials rejected on the transport channel) and `PERMISSION_DENIED` (authenticated caller not authorized for the action). Distinct from the receiver-side credentials carried in `push_notification_config.authentication.credentials`, which configure the seller's webhook callback authentication and are not buyer-principal or evaluator-call credentials — those are an explicit carve-out and MUST NOT trigger this code. Sellers SHOULD reject credential-in-args under AdCP 3.1; the requirement upgrades to MUST 90 days after the 3.1 publication date. Recovery: terminal — the agent MUST NOT auto-retry. Auto-retry against this code re-logs the credential on each attempt across the seller's request logs, observability stack, and any LLM-context surfaces in the buyer-side recovery loop, exactly the prompt-injection exfiltration surface that motivated the rule. Wire placement. Sellers MUST flip transport-level failure markers (HTTP 4xx, MCP `isError: true`, A2A `failed`) and populate both layers per the two-layer model in `error-handling.mdx#envelope-vs-payload-errors-the-two-layer-model`. The code itself is the discriminator; no `error.details` shape is defined, and `error.field` MUST NOT echo the offending credential value or any prefix of it (e.g., `\"Bearer ey...\"`) — the path that triggered detection is sufficient (e.g., `request.access_token`, `request.context.credentials`, `request.ext.api_key`, `request.evaluator.ext.api_key`). `error.message` MUST be generic and MUST NOT contain credential material. Sellers MUST drop the smuggled credential from logs, audit rows, and observability spans before persisting the rejection — the rejection itself is otherwise an exfiltration surface.", + "CREDENTIAL_IN_ARGS": "The seller detected authentication material or caller-supplied trust material placed in request args (top-level, in `context`, in `ext`, or any other nested location in the task payload) instead of arriving on the relevant transport authentication or trust channel. This includes buyer-principal credentials that should arrive on the inbound transport (`Authorization: Bearer` per RFC 6750 §2 for HTTP, RFC 9421 signature headers for signed requests, MCP/A2A authentication framing per RFC 9728 §3), and evaluator-call credentials or JWK/JWKS/JWKS-URI trust material smuggled into evaluator-related payload fields instead of being established through the creative agent's outbound transport authentication to the evaluator. Distinct from `AUTH_REQUIRED` (no credentials presented or presented credentials rejected on the transport channel) and `PERMISSION_DENIED` (authenticated caller not authorized for the action). Distinct from the receiver-side credentials carried in `push_notification_config.authentication.credentials`, which configure the seller's webhook callback authentication and are not buyer-principal or evaluator-call credentials — those are an explicit carve-out and MUST NOT trigger this code. Sellers SHOULD reject credential-in-args under AdCP 3.1; the requirement upgrades to MUST 90 days after the 3.1 publication date. Recovery: terminal — the agent MUST NOT auto-retry. Auto-retry against this code re-logs the credential on each attempt across the seller's request logs, observability stack, and any LLM-context surfaces in the buyer-side recovery loop, exactly the prompt-injection exfiltration surface that motivated the rule. Wire placement. Sellers MUST flip transport-level failure markers (HTTP 4xx, MCP `isError: true`, A2A `failed`) and populate both layers per the two-layer model in `error-handling.mdx#envelope-vs-payload-errors-the-two-layer-model`. The code itself is the discriminator; no `error.details` shape is defined, and `error.field` MUST NOT echo the offending credential value or any prefix of it (e.g., `\"Bearer ey...\"`). `error.message` MUST be generic and MUST NOT contain credential material. Sellers MUST drop the smuggled credential from logs, audit rows, and observability spans before persisting the rejection — the rejection itself is otherwise an exfiltration surface.", "ACTION_NOT_ALLOWED": "The requested mutation maps to an action that is not currently available on this media buy. Sellers MUST populate `error.details` with `attempted_action` (the `media_buy_valid_action` value the request maps to), `reason` (an `action-not-allowed-reason` value: `wrong_status`, `not_supported_on_product`, `not_supported_on_buy`, or `mode_mismatch`), and `currently_available_actions` (echo of the buy's resolved `available_actions[]` so the buyer SDK can offer recovery without a separate get_media_buys round-trip). Recovery: correctable when `reason` is `wrong_status` (wait for or transition to an allowed status) or `mode_mismatch` (re-issue through the appropriate flow). Terminal-for-this-buy when `reason` is `not_supported_on_product` or `not_supported_on_buy` — buyers select a different product or renegotiate buy terms.", "PRIVATE_FIELD_IN_PUBLIC_PLACEMENT": "Fatal producer-side error raised when a public placement object (`Product.placements[]` in `get_products` or `placements[]` in adagents.json) exposes seller-private operational fields such as `visibility`, `source`, `origin`, or `delivery_mappings`. This is a private-data leak, not an ordinary syntactic mismatch. Consumers that detect it MUST fail closed for that placement and surface this code so monitoring can alarm on the leak specifically instead of burying it under generic schema validation. `error.field` SHOULD point at the offending placement path and `error.details` SHOULD carry `{ placement_id, leaked_fields: [] }` without echoing private field values. Recovery: correctable but seller-side — remove private operational fields from the public placement surface and keep delivery mappings in seller-internal systems.", "FORMAT_PROJECTION_FAILED": "Non-fatal advisory raised when a legacy named format on a product cannot be projected to a canonical-formats `ProductFormatDeclaration` via the resolution order in `v1-canonical-mapping.json` (explicit `canonical` field → format_id_glob → structural match → fail-closed). The product is still valid on the legacy named-format path; only the 3.1+ `format_options` projection failed. Primarily a **consumer-SDK concern** — the seller didn't fail; the consumer-side SDK couldn't project on their behalf. `error.field` MUST point at the offending product (e.g., `products[3].format_ids[0]`); `error.details` SHOULD carry `{ format_id, product_id, resolution_failure: \"no_explicit_canonical\" | \"no_registry_match\" | \"no_structural_match\" }` so buyer SDKs can route remediation (suggest the seller add an explicit `canonical` field, or file a registry PR).\n\n**Surface placement (normative).** SDKs that detect this on consumption MUST augment the response's `errors[]` array with an entry carrying `source: \"sdk\"`, `sdk_id: \"@\"`, `code: \"FORMAT_PROJECTION_FAILED\"`, and the field+details described above. This is the single mandated surface — logger-only is insufficient and a separate lint-output channel is NOT acceptable (AdCP is a multi-hop agent network; warnings need to propagate across hops or each hop has to re-detect locally). Sellers MAY emit this code on their own response when they self-detect a non-projectable format on emit; producer-emitted entries omit `source` (or set `source: \"producer\"`). The response stays 200/success regardless of who emits; this is non-fatal.\n\n**Multi-hop deduplication.** Each hop that detects the same condition SHOULD deduplicate by `(code, field)` rather than re-emit. The existing entry's `sdk_id` identifies which earlier processor saw it first; downstream SDKs SHOULD NOT add a second entry for the same `(code, field)` pair unless they have materially different `error.details` (e.g., a different `resolution_failure` reason from a different registry version).\n\nRecovery: correctable (seller-side action — add explicit `canonical` field on the legacy format file, contribute a v1-canonical-mapping registry entry, or author a 3.1+ `ProductFormatDeclaration` with `v1_format_ref` linking back). See canonical-formats.mdx 'Dual emission and v2↔v1 projection' for the full rules.", @@ -193,7 +193,7 @@ "FORMAT_DECLARATION_V1_AMBIGUOUS": "Non-fatal advisory raised when an SDK detects that a product's v2 declaration cannot be unambiguously projected back to a single v1 named format because the v1-canonical-mapping registry has only family-level structural entries for this canonical (no invertible `format_id_glob` literal). The family is known (e.g., 'this is a video_vast'); the specific v1 named format isn't pickable mechanically. Distinct from `FORMAT_PROJECTION_FAILED` (registry-coverage gap, correctable by adding a registry entry) — ambiguity is structural: the family is defined but a specific format can't be picked without seller assertion.\n\nSurface placement: same single-mandate as `FORMAT_PROJECTION_FAILED` and `FORMAT_DECLARATION_DIVERGENT` — SDKs MUST augment the response's `errors[]` array with an entry carrying `source: \"sdk\"`, `sdk_id`, `code: \"FORMAT_DECLARATION_V1_AMBIGUOUS\"`, `field` pointing at the offending declaration, and `error.details` SHOULD carry `{ format_kind, registry_matches: [], product_id }` so adopters can see why the inversion was ambiguous.\n\n**SDKs MUST NOT synthesize a v1_format_ref** in this case (or any other case). v1↔v2 explicit pairing is seller-asserted only — SDKs encountering family-only registry matches MUST treat the v2 declaration as v1-unreachable and surface this code rather than invent a plausible v1 format_id. The seller's path: author `v1_format_ref` on the v2 declaration to disambiguate (the authoritative pairing per `v1-canonical-mapping.json` resolution step 1), or accept that v1-only buyers won't see this product. Recovery: correctable but seller-side — buyer can't disambiguate without the seller's assertion.", "FORMAT_OPTION_UNRESOLVED": "Non-fatal advisory raised when a placement in `adagents.json` (or any consumer of `placement-definition.json`) carries `format_options[].format_option_id` referencing a `format_option_id` that does NOT exist in the file's top-level `formats[]`. The reference is broken — the publisher's catalog claims the placement accepts a format option that isn't declared.\n\n**Resolution scope is same-file only.** Cross-file `format_option_id` lookup is not supported by design (closes off format_option_id squatting across publisher boundaries — a malicious file cannot reference another publisher's format_option_id and claim its narrowing). Buyer SDKs MUST fail closed for the placement (drop the format from the placement's accepted format set) and MUST surface this code rather than silently dropping or guessing what the publisher meant.\n\nSurface placement: same single-mandate as the other FORMAT_* codes — SDKs that detect on consumption MUST augment the response's `errors[]` with `source: \"sdk\"`, `sdk_id`, `code: \"FORMAT_OPTION_UNRESOLVED\"`, `field` pointing at the offending placement (e.g., `placements[2].format_options[1].format_option_id`), and `error.details` SHOULD carry `{ placement_id, format_option_id, declared_format_options: [] }` so the publisher can fix.\n\nRecovery: correctable but publisher-side — buyer can't fix; publisher either declares the missing format option in `formats[]` or removes the reference from the placement.", "FORMAT_NOT_SUPPORTED": "A requested creative operation route is not supported by this creative agent. On the canonical 3.2 path, returned when build_creative.target_capability_id(s), preview_creative.target_capability_id, or validate_input targets[] kind capability does not match an advertised creative.supported_formats[].capability_id carrying the requested operation. Also returned when preview renderer inference has zero or multiple compatible matches. Sellers SHOULD attribute the error to the selector field and MAY include supported capability IDs in error.details.supported_capability_ids when safe. Deprecated target_format_id(s) and preview format_id retain legacy named-format error attribution during the 3.x compatibility window. Recovery: correctable (read get_adcp_capabilities creative.supported_formats[] and retry with a capability_id whose operations contains the intended operation).", - "FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE": "Non-fatal advisory raised when a v2 declaration carries `params.sizes[]` with N entries but only M v1_format_ref entries (M < N). The seller has asserted some v1 named formats but not enough to cover all declared sizes — v1-only buyers see partial coverage. Emitted **alongside** the partial v1 emission (NOT in place of it): the product still appears on the v1 wire under the M sizes the seller covered; this code tells v1-aware downstream agents that N-M sizes were dropped from the projection.\n\nSurface placement: SDKs that detect on emission OR consumption MUST augment the response's `errors[]` with `source: \"sdk\"` (or `\"producer\"` if the seller self-detects on emit), `sdk_id`, `code: \"FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE\"`, `field` pointing at the offending declaration, and `error.details` SHOULD carry `{ product_id, declared_sizes: [{w,h}, …], covered_sizes: [{w,h}, …], dropped_sizes: [{w,h}, …] }` so buyer agents see which sizes were lost.\n\n**SDKs MAY (non-normative) fan out automatically** by catalog lookup — for each entry in `sizes[]` lacking a corresponding `v1_format_ref`, the SDK consults the AAO catalog for the per-size v1 named format (e.g., for `{width: 728, height: 90}` look up `display_728x90_image`) and emits it under `format_ids[]`. This is opt-in (requires catalog access); when SDKs fan out, they SHOULD still emit this code as a transparency advisory so downstream consumers know the v1 emit was synthesized rather than seller-asserted.\n\nRecovery: warning — non-fatal, no retry. Seller fix: add `v1_format_ref[]` entries for the missing sizes.", + "FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE": "Non-fatal advisory raised when a v2 declaration carries `params.sizes[]` with N entries but only M v1_format_ref entries (M < N). The seller has asserted some v1 named formats but not enough to cover all declared sizes — v1-only buyers see partial coverage on the product. Emitted **alongside** the partial v1 emission (NOT in place of it): the product still appears on the v1 wire under the M sizes the seller covered; this code tells v1-aware downstream agents that N-M sizes were dropped from the projection.\n\nSurface placement: SDKs that detect on emission OR consumption MUST augment the response's `errors[]` with `source: \"sdk\"` (or `\"producer\"` if the seller self-detects on emit), `sdk_id`, `code: \"FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE\"`, `field` pointing at the offending declaration, and `error.details` SHOULD carry `{ product_id, declared_sizes: [{w,h}, …], covered_sizes: [{w,h}, …], dropped_sizes: [{w,h}, …] }` so buyer agents see which sizes were lost.\n\n**SDKs MAY (non-normative) fan out automatically** by catalog lookup — for each entry in `sizes[]` lacking a corresponding `v1_format_ref`, the SDK consults the AAO catalog for the per-size v1 named format (e.g., for `{width: 728, height: 90}` look up `display_728x90_image`) and emits it under `format_ids[]`. This is opt-in (requires catalog access); when SDKs fan out, they SHOULD still emit this code as a transparency advisory so downstream consumers know the v1 emit was synthesized rather than seller-asserted.\n\nRecovery: warning — non-fatal, no retry. Seller fix: add `v1_format_ref[]` entries for the missing sizes.", "PIXEL_TRACKER_LOSSY_DOWNGRADE": "Non-fatal advisory raised when a 3.1 buyer SDK downgrades a `pixel_tracker` asset to the v1 `{asset_type: url, url_type: tracker_pixel}` shape for a 3.0.x seller that doesn't recognize the new asset type. The URL is still emitted on the wire and the seller will fire it as a tracker pixel; what's lost is the event/method discrimination.\n\nDowngrade rules (normative):\n- `event: impression` + `method: img` → no loss; emit as `{asset_type: url, url_type: tracker_pixel, url, asset_id: impression_tracker}`\n- `event: viewable_mrc_50` / `viewable_mrc_100` / `viewable_video_50` / `audible_video_complete` → emit with `asset_id: viewability_tracker`; advisory `lost_event: ` (specific viewability variant collapses to a single v1 slot)\n- `event: click` → emit with `asset_id: click_tracker`; no meaningful loss\n- `event: custom, custom_event_name: X` → emit with `asset_id: impression_tracker` (default tracker_pixel fires on impression); advisory `lost_event: \"custom\"`, `lost_custom_event_name: X` (custom event timing collapses to impression timing)\n- `method: js` → emit unchanged shape (url, url_type:tracker_pixel); advisory `lost_method: \"js\"` (v1 seller will fire as HTTP GET; the URL is hit and any counter-based measurement increments, but the response body won't execute as JS — measurement that depends on JS execution, e.g., OMID-style verification, viewability observers, cross-domain cookie setters, won't work. Simple counter pixels still work.)\n\nSurface: SDK that performs the downgrade MUST augment the response's `errors[]` with `source: \"sdk\"`, `sdk_id`, `code: \"PIXEL_TRACKER_LOSSY_DOWNGRADE\"`, `field` pointing at the affected manifest asset path, and `error.details` SHOULD carry `{ asset_id, original_event, original_method, original_custom_event_name (if present), downgrade_target: \"url+tracker_pixel\", lost_fields: [] }`. One advisory per downgraded asset; SDKs SHOULD NOT collapse multiple downgrades into a single advisory entry — per-asset details let the buyer's measurement-plan owner decide whether each loss is tolerable.\n\nRecovery: warning — non-fatal, no retry. Buyer-side decision: accept the loss (most simple counter pixels survive), or fail the buy and route to a 3.1-capable seller. Seller-side fix: upgrade to 3.1 and accept `pixel_tracker` natively.", "PIXEL_TRACKER_UPGRADE_INFERRED": "Non-fatal advisory raised when a 3.1 buyer SDK upgrades a v1 `{asset_type: url, url_type: tracker_pixel}` to a `pixel_tracker` asset by INFERRING the event and method from the v1 asset_id and conventional defaults. The inference is structural — the SDK doesn't have explicit event/method values, only the v1 asset_id hint and `url_type: tracker_pixel` (which implies `method: img` by default).\n\nInference rules (normative):\n- `asset_id: impression_tracker` → `event: impression, method: img`\n- `asset_id: viewability_tracker` → `event: viewable_mrc_50, method: img` (50% is the most common default; specific viewability variant cannot be recovered from v1 shape)\n- `asset_id: click_tracker` → `event: click, method: img`\n- `asset_id: ` → `event: custom, custom_event_name: , method: img`\n\nSurface: SDK MUST augment the response's `errors[]` with `code: \"PIXEL_TRACKER_UPGRADE_INFERRED\"`, `field` pointing at the upgraded asset path, and `error.details` SHOULD carry `{ asset_id, inferred_event, inferred_method, inference_basis: \"asset_id_convention\" | \"default\" }`. Buyer agents reading the response can re-prompt the seller for explicit values if precise measurement matters.\n\nRecovery: warning — non-fatal, no retry. Seller-side: upgrade emit path to ship pixel_tracker shape directly when 3.1-capable; until then, conventional asset_id values give the SDK enough signal to upgrade without losing critical semantics.", "STALE_RESPONSE": "Non-fatal advisory raised when the seller's live fetch to an upstream or sub-agent failed (timeout, connection error, downstream 5xx) and the response payload was satisfied from a cached prior result that is past the seller's freshness target for this surface. Emitted **alongside** a populated success payload — the caller's request still completes from a usable cache hit; this code tells downstream consumers that the data is older than the seller would normally serve.\n\nDistinct from `SERVICE_UNAVAILABLE` (seller's own service is down, no payload — transient, retry-with-backoff) by signalling **graceful degradation**: the seller's own service is fine, but one of its dependencies is currently unreachable and the seller chose to honor the request from cache rather than return empty. Sellers MUST emit `STALE_RESPONSE` ONLY when the response payload is non-empty AND derived from a cache entry whose `cache_age_seconds` exceeds the surface's freshness target. When no cached entry exists (or the cache hit is within freshness target), sellers MUST NOT emit this code — return the empty-or-fresh response with whatever upstream-failure code applies (e.g., `SERVICE_UNAVAILABLE`).\n\n**Wire placement (normative).** Transport-level success markers stay flipped to success (HTTP 200, MCP `isError: false`, A2A `succeeded`) — the task ran successfully and produced a response, even if from cache. The advisory rides in `errors[]` on the payload and MUST NOT be promoted to `adcp_error` on the envelope (envelope-level errors are reserved for the empty-payload failure case per the two-layer model in `error-handling.mdx#envelope-vs-payload-errors-the-two-layer-model`). `error.field` SHOULD point at the affected payload path (e.g., `formats` for `list_creative_formats`, `products` for `get_products`). `error.details` SHOULD conform to `error-details/stale-response.json` — `served_from_cache` (required, always `true`), `cache_age_seconds` (required), and optionally `freshness_target_seconds`, `upstream` (the dependency that failed), and `original_error` (the underlying failure code/message).\n\n**Multiple stale upstreams.** When N sub-agents are stale (e.g., a `list_creative_formats` registry aggregating from multiple creative agents), the seller SHOULD emit **one `STALE_RESPONSE` entry per affected upstream** rather than aggregating — the per-upstream shape mirrors the existing precedent set by `PIXEL_TRACKER_LOSSY_DOWNGRADE` (one advisory per downgraded asset) and lets buyer agents reason about which sub-population of the payload is stale. Each entry's `error.field` SHOULD narrow to the affected slice (e.g., `formats` for formats sourced from the stale upstream).\n\nRecovery: transient — buyers MUST treat as non-fatal (the response is usable), MAY immediately retry later for fresh data, and SHOULD surface staleness to operators or end users when relevant. `cache_age_seconds` is the informational knob for the buyer's own \"is this stale enough to bother retrying\" policy.", @@ -420,7 +420,7 @@ }, "MULTI_FINALIZE_UNSUPPORTED": { "recovery": "correctable", - "suggestion": "sequence single-proposal finalize calls — one finalize entry per get_products call" + "suggestion": "sequence single-proposal finalize calls — one finalize entry per get_products refine[] call or per refine_proposals refinements[] call" }, "IO_REQUIRED": { "recovery": "correctable", From 2534a1a980851628f7dd61dded90d0cfc54af4ee Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 13 Aug 2026 15:38:02 +0200 Subject: [PATCH 45/49] docs: refresh generated compliance error codes --- docs/snippets/compliance-error-codes.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/snippets/compliance-error-codes.mdx b/docs/snippets/compliance-error-codes.mdx index ca9c8f9ef7..f46d2da894 100644 --- a/docs/snippets/compliance-error-codes.mdx +++ b/docs/snippets/compliance-error-codes.mdx @@ -70,7 +70,7 @@ description: "Canonical AdCP error codes with recovery classifications, remediat | `IO_REQUIRED` | correctable | review the proposal's insertion_order, accept terms, and include io_acceptance on accept_proposal; use create_media_buy only for 3.x compatibility | | `ITEM_VALIDATION_FAILED` | correctable | check item_issues for per-item rejection reasons and fix the offending items | | `MEDIA_BUY_NOT_FOUND` | correctable | verify media_buy_id; for legacy correlation use get_media_buys plus context, such as context.internal_campaign_id | -| `MULTI_FINALIZE_UNSUPPORTED` | correctable | sequence single-proposal finalize calls — one finalize entry per get_products call | +| `MULTI_FINALIZE_UNSUPPORTED` | correctable | sequence single-proposal finalize calls — one finalize entry per get_products refine[] call or per refine_proposals refinements[] call | | `NOT_CANCELLABLE` | correctable | check the seller's cancellation policy or contact the seller | | `PACKAGE_NOT_FOUND` | correctable | verify package_id; for legacy package correlation use get_media_buys plus package context, such as context.buyer_ref | | `PAYMENT_TERMS_NOT_SUPPORTED` | correctable | omit payment_terms to accept the seller's default, retry with a different supported value, or negotiate offline | @@ -435,7 +435,7 @@ A submitted text-asset value is not in the format's declared {"allowed_val **Suggested action:** do NOT auto-retry — auto-retry re-logs the credential on each attempt. Move authentication material or caller-supplied trust material out of request args (top-level, {"context"}, {"ext"}, any nested location) onto the relevant transport authentication/trust channel or account provisioning path (Authorization: Bearer, RFC 9421 signature/JWKS, mTLS, MCP/A2A authentication framing); rotate any leaked credential, then resubmit -The seller detected authentication material or caller-supplied trust material placed in request args (top-level, in {"context"}, in {"ext"}, or any other nested location in the task payload) instead of arriving on the relevant transport authentication or trust channel. This includes buyer-principal credentials that should arrive on the inbound transport ({"Authorization: Bearer"} per RFC 6750 §2 for HTTP, RFC 9421 signature headers for signed requests, MCP/A2A authentication framing per RFC 9728 §3), and evaluator-call credentials or JWK/JWKS/JWKS-URI trust material smuggled into evaluator-related payload fields instead of being established through the creative agent's outbound transport authentication to the evaluator. Distinct from {"AUTH_REQUIRED"} (no credentials presented or presented credentials rejected on the transport channel) and {"PERMISSION_DENIED"} (authenticated caller not authorized for the action). Distinct from the receiver-side credentials carried in {"push_notification_config.authentication.credentials"}, which configure the seller's webhook callback authentication and are not buyer-principal or evaluator-call credentials — those are an explicit carve-out and MUST NOT trigger this code. Sellers SHOULD reject credential-in-args under AdCP 3.1; the requirement upgrades to MUST 90 days after the 3.1 publication date. Recovery: terminal — the agent MUST NOT auto-retry. Auto-retry against this code re-logs the credential on each attempt across the seller's request logs, observability stack, and any LLM-context surfaces in the buyer-side recovery loop, exactly the prompt-injection exfiltration surface that motivated the rule. Wire placement. Sellers MUST flip transport-level failure markers (HTTP 4xx, MCP {"isError: true"}, A2A {"failed"}) and populate both layers per the two-layer model in {"error-handling.mdx#envelope-vs-payload-errors-the-two-layer-model"}. The code itself is the discriminator; no {"error.details"} shape is defined, and {"error.field"} MUST NOT echo the offending credential value or any prefix of it (e.g., {"\"Bearer ey...\""}) — the path that triggered detection is sufficient (e.g., {"request.access_token"}, {"request.context.credentials"}, {"request.ext.api_key"}, {"request.evaluator.ext.api_key"}). {"error.message"} MUST be generic and MUST NOT contain credential material. Sellers MUST drop the smuggled credential from logs, audit rows, and observability spans before persisting the rejection — the rejection itself is otherwise an exfiltration surface. +The seller detected authentication material or caller-supplied trust material placed in request args (top-level, in {"context"}, in {"ext"}, or any other nested location in the task payload) instead of arriving on the relevant transport authentication or trust channel. This includes buyer-principal credentials that should arrive on the inbound transport ({"Authorization: Bearer"} per RFC 6750 §2 for HTTP, RFC 9421 signature headers for signed requests, MCP/A2A authentication framing per RFC 9728 §3), and evaluator-call credentials or JWK/JWKS/JWKS-URI trust material smuggled into evaluator-related payload fields instead of being established through the creative agent's outbound transport authentication to the evaluator. Distinct from {"AUTH_REQUIRED"} (no credentials presented or presented credentials rejected on the transport channel) and {"PERMISSION_DENIED"} (authenticated caller not authorized for the action). Distinct from the receiver-side credentials carried in {"push_notification_config.authentication.credentials"}, which configure the seller's webhook callback authentication and are not buyer-principal or evaluator-call credentials — those are an explicit carve-out and MUST NOT trigger this code. Sellers SHOULD reject credential-in-args under AdCP 3.1; the requirement upgrades to MUST 90 days after the 3.1 publication date. Recovery: terminal — the agent MUST NOT auto-retry. Auto-retry against this code re-logs the credential on each attempt across the seller's request logs, observability stack, and any LLM-context surfaces in the buyer-side recovery loop, exactly the prompt-injection exfiltration surface that motivated the rule. Wire placement. Sellers MUST flip transport-level failure markers (HTTP 4xx, MCP {"isError: true"}, A2A {"failed"}) and populate both layers per the two-layer model in {"error-handling.mdx#envelope-vs-payload-errors-the-two-layer-model"}. The code itself is the discriminator; no {"error.details"} shape is defined, and {"error.field"} MUST NOT echo the offending credential value or any prefix of it (e.g., {"\"Bearer ey...\""}). {"error.message"} MUST be generic and MUST NOT contain credential material. Sellers MUST drop the smuggled credential from logs, audit rows, and observability spans before persisting the rejection — the rejection itself is otherwise an exfiltration surface. @@ -503,7 +503,7 @@ Surface placement: same single-mandate as {"FORMAT_PROJECTION_FAILED"}{"params.sizes[]"} with N entries but only M v1_format_ref entries (M < N), so v1 buyers see partial size coverage on the product. Seller-side fix: add v1_format_ref entries for the missing sizes. SDK MAY (non-normative) fan out automatically by catalog lookup. Non-fatal — do not auto-retry -Non-fatal advisory raised when a v2 declaration carries {"params.sizes[]"} with N entries but only M v1_format_ref entries (M < N). The seller has asserted some v1 named formats but not enough to cover all declared sizes — v1-only buyers see partial coverage. Emitted **alongside** the partial v1 emission (NOT in place of it): the product still appears on the v1 wire under the M sizes the seller covered; this code tells v1-aware downstream agents that N-M sizes were dropped from the projection. +Non-fatal advisory raised when a v2 declaration carries {"params.sizes[]"} with N entries but only M v1_format_ref entries (M < N). The seller has asserted some v1 named formats but not enough to cover all declared sizes — v1-only buyers see partial coverage on the product. Emitted **alongside** the partial v1 emission (NOT in place of it): the product still appears on the v1 wire under the M sizes the seller covered; this code tells v1-aware downstream agents that N-M sizes were dropped from the projection. Surface placement: SDKs that detect on emission OR consumption MUST augment the response's {"errors[]"} with {"source: \"sdk\""} (or {"\"producer\""} if the seller self-detects on emit), {"sdk_id"}, {"code: \"FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE\""}, {"field"} pointing at the offending declaration, and {"error.details"} SHOULD carry {"{ product_id, declared_sizes: [{w,h}, …], covered_sizes: [{w,h}, …], dropped_sizes: [{w,h}, …] }"} so buyer agents see which sizes were lost. @@ -682,9 +682,9 @@ Referenced media buy does not exist or is not accessible to the requesting agent -**Suggested action:** sequence single-proposal finalize calls — one finalize entry per get_products call +**Suggested action:** sequence single-proposal finalize calls — one finalize entry per get_products refine[] call or per refine_proposals refinements[] call -Returned by sellers that cannot guarantee atomic commit across multiple proposals in a single {"get_products"} call (multiple {"action: 'finalize'"} entries in {"refine[]"} targeting different {"proposal_id"} values). The buyer's intent — atomic multi-proposal finalize — is structurally well-formed and per spec atomic, but this seller's downstream stack cannot satisfy the atomicity guarantee (e.g., the proposals route to two different ad servers with no 2PC). More specific than {"INVALID_REQUEST"} so buyers can distinguish 'this seller doesn't support multi-finalize' from 'the request itself is malformed'. Recovery: correctable (sequence the finalize calls one-{"proposal_id"}-per-call; there is no capability flag for multi-finalize support, so a successful first-attempt is the only positive discovery signal). See [refinement guide § Finalize is exclusive](/docs/media-buy/product-discovery/refinement#finalize-is-exclusive-within-refine). +Returned by sellers that cannot guarantee atomic commit across multiple proposals in a single finalize batch. Two call sites where this applies: (1) a {"get_products"} call with multiple {"action: 'finalize'"} entries in {"refine[]"} targeting different {"proposal_id"} values; (2) a {"refine_proposals"} call with multiple {"action: 'finalize'"} entries in {"refinements[]"} targeting different {"proposal_id"} values. The buyer's intent — atomic multi-proposal finalize — is structurally well-formed and per spec atomic on both surfaces, but this seller's downstream stack cannot satisfy the atomicity guarantee (e.g., the proposals route to two different ad servers with no 2PC). More specific than {"INVALID_REQUEST"} so buyers can distinguish 'this seller doesn't support multi-finalize' from 'the request itself is malformed'. Recovery: correctable (sequence the finalize calls one-{"proposal_id"}-per-call on whichever task surface the buyer is using; there is no capability flag for multi-finalize support, so a successful first-attempt is the only positive discovery signal). See [refinement guide § Finalize is exclusive](/docs/media-buy/product-discovery/refinement#finalize-is-exclusive-within-refine). From 637fbd7cb8bf763243d7bf06eb90a4474886c5e5 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 14 Aug 2026 09:53:48 +0200 Subject: [PATCH 46/49] feat(accounts): define advertiser account keys --- .changeset/secure-get-products-idempotency.md | 2 +- docs/accounts/overview.mdx | 6 +- docs/accounts/tasks/list_accounts.mdx | 14 +- docs/accounts/tasks/sync_accounts.mdx | 33 ++- docs/building/by-layer/L1/security.mdx | 2 +- .../by-layer/L2/accounts-and-agents.mdx | 19 +- .../task-reference/request_proposals.mdx | 15 +- docs/protocol/calling-an-agent.mdx | 4 +- docs/protocol/get_adcp_capabilities.mdx | 4 +- server/src/training-agent/account-handlers.ts | 196 ++++++++++++++---- server/src/training-agent/account-scope.ts | 110 ++++++---- server/src/training-agent/source-schema.ts | 14 +- server/src/training-agent/task-handlers.ts | 96 +++++---- server/src/training-agent/types.ts | 12 +- server/tests/unit/account-handlers.test.ts | 33 ++- .../product-discovery-schema-parity.test.ts | 5 + .../unit/training-agent-account-scope.test.ts | 63 ++++-- server/tests/unit/training-agent.test.ts | 33 +++ .../source/account/list-accounts-request.json | 4 +- .../account/list-accounts-response.json | 16 +- .../source/account/sync-accounts-request.json | 47 +++-- .../account/sync-accounts-response.json | 19 +- static/schemas/source/core/account-ref.json | 16 +- static/schemas/source/core/account.json | 12 +- static/schemas/source/core/brand-key.json | 15 +- static/schemas/source/core/brand-ref.json | 15 +- .../source/core/canonical-account-ref.json | 19 +- static/schemas/source/core/operator-unit.json | 29 +++ .../schemas/source/core/x-entity-types.json | 2 + .../get-adcp-capabilities-response.json | 2 +- tests/composed-schema-validation.test.cjs | 25 ++- 31 files changed, 636 insertions(+), 246 deletions(-) create mode 100644 static/schemas/source/core/operator-unit.json diff --git a/.changeset/secure-get-products-idempotency.md b/.changeset/secure-get-products-idempotency.md index 51481c8771..4a2bb49988 100644 --- a/.changeset/secure-get-products-idempotency.md +++ b/.changeset/secure-get-products-idempotency.md @@ -6,4 +6,4 @@ Add the compact AdCP 3.2 product and MediaBuy lifecycle: `list_products`, `reque Preserve the AdCP 3.1 inventory-reservation contract in the compact lifecycle: requested and revised proposals remain immutable drafts until `refine_proposals` finalizes them, and a finalized `committed` snapshot guarantees inventory is held until `expires_at`. Add `countries` and `property_list` product-attribute filters, and publish compact task-specific async envelopes for consultative proposal planning and re-underwriting. -Allow compact BrandKeys to identify a country market and compact natural accounts to identify an operator business region, keeping regional brand/operator account relationships distinct without adding delivery targeting to identity fields. +Let compact BrandKeys qualify commercial advertiser identity with canonical `countries[]` without turning identity into delivery targeting. Extend buyer-declared natural accounts with an operator-owned unit (`id` plus mutable display `name`), optional immutable account currency, and sandbox identity. These fields round-trip through `sync_accounts` and `list_accounts`; the operator unit remains explicitly distinct from the seller/storefront `account_id`. diff --git a/docs/accounts/overview.mdx b/docs/accounts/overview.mdx index 40e588a474..ecf13429f6 100644 --- a/docs/accounts/overview.mdx +++ b/docs/accounts/overview.mdx @@ -43,7 +43,7 @@ The Accounts Protocol applies across all vendor protocols. An orchestrator estab | Governance | Content standards billing | | Creative | Creative service billing | -The account reference may be a seller-assigned `account_id` (seller-owned namespaces, usually `require_operator_auth: true`) or a natural key — `brand` + `operator` + optional `operator_region` (buyer-declared accounts, `require_operator_auth: false`). `brand.market` may independently distinguish a country-market identity. For buyer-declared accounts, the complete natural-key `AccountRef` MUST remain valid on subsequent calls even if the seller also echoes an internal `account_id`, whether the account was provisioned through `sync_accounts` or lazily on the first account-scoped request. For sandbox, account-id namespaces use pre-existing test accounts discovered via `list_accounts` or supplied out-of-band. Buyer-declared accounts use `sandbox: true` in `sync_accounts`, or in the natural-key `AccountRef` when a lazy-provisioning seller declares sandbox support and needs no other buyer-supplied setup. See [Account references](/docs/building/by-layer/L2/accounts-and-agents#account-references) for details. +The account reference may be a seller/storefront-assigned `account_id` (seller-owned namespaces, usually `require_operator_auth: true`) or a buyer-declared advertiser key — `brand` + `operator` + optional `operator_unit`, `currency`, and `sandbox` (`require_operator_auth: false`). The brand may include `countries[]` to distinguish commercial advertiser entities without specifying delivery targeting. `operator_unit.id` identifies an operator-owned unit or platform seat; it is deliberately separate from the seller's `account_id`. For buyer-declared accounts, the complete natural-key `AccountRef` MUST remain valid on subsequent calls and round-trip through `list_accounts`, even if the seller also echoes an internal `account_id`. See [Account references](/docs/building/by-layer/L2/accounts-and-agents#account-references) for details. ## Account Status Lifecycle @@ -289,14 +289,14 @@ The Accounts Protocol operates with four party types. See [Accounts and agents]( | Party | Role | Identified by | | ------------ | ----------------------------- | --------------------------------------------------------- | -| Brand | Whose products are advertised | `brand.domain` + optional `brand.brand_id`/`brand.market` via brand.json | +| Brand | Whose products are advertised | `brand.domain` + optional `brand.brand_id`/`brand.countries[]` via brand.json | | Operator | Who drives the buys | Domain (e.g., `pinnacle-media.com`) | | Agent | What software places the buys | Authenticated session | | Vendor agent | The seller's AdCP agent | `agent_url` | ## Tasks -**Account discovery (normative).** Every agent accepting accounts MUST expose at least one of `list_accounts` or `sync_accounts`. A seller-defined account-id namespace MAY omit both only when account IDs are supplied out-of-band and no account settings are managed through AdCP. Buyer-declared-account sellers normally expose `sync_accounts` to establish the relationship and SHOULD also expose `list_accounts` for cold-start recovery: a stateless buyer cannot reconstruct the lost set of `(brand, operator, operator_region, sandbox)` natural keys by re-running `sync_accounts`. A buyer-declared seller MAY omit `sync_accounts` only when it lazily provisions from the complete natural key, can resolve every required setting without buyer input, and exposes `list_accounts`; this makes auto-provisioning plus a read-only `list_accounts` conformant for that narrow case. For account-id namespaces, `sync_accounts` is settings-update only in 3.0.x unless a future explicit capability declares account-id provisioning. See [Required tasks by protocol](/docs/protocol/required-tasks#any-agent-accepting-accounts). +**Account discovery (normative).** Every agent accepting accounts MUST expose at least one of `list_accounts` or `sync_accounts`. A seller-defined account-id namespace MAY omit both only when account IDs are supplied out-of-band and no account settings are managed through AdCP. Buyer-declared-account sellers normally expose `sync_accounts` to establish the relationship and SHOULD also expose `list_accounts` for cold-start recovery: a stateless buyer cannot reconstruct lost advertiser natural keys by replaying `sync_accounts`. `list_accounts` therefore returns the brand, operator, and optional operator unit, currency, and sandbox fields needed to compose the reference again. A buyer-declared seller MAY omit `sync_accounts` only when it lazily provisions from the complete natural key, can resolve every required setting without buyer input, and exposes `list_accounts`. For account-id namespaces, `sync_accounts` is settings-update only in 3.0.x unless a future explicit capability declares account-id provisioning. See [Required tasks by protocol](/docs/protocol/required-tasks#any-agent-accepting-accounts). | Task | Purpose | | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/accounts/tasks/list_accounts.mdx b/docs/accounts/tasks/list_accounts.mdx index ec45cb095c..7676892501 100644 --- a/docs/accounts/tasks/list_accounts.mdx +++ b/docs/accounts/tasks/list_accounts.mdx @@ -5,7 +5,7 @@ description: "list_accounts returns all advertiser accounts an authenticated age testable: false --- -Returns all accounts the authenticated agent can operate on this vendor agent. Use this to discover existing accounts, check status changes on pending accounts, and retrieve `account_id` values for use in protocol operations. +Returns all accounts the authenticated agent can operate on this vendor agent. Use this to discover existing accounts, check status changes on pending accounts, and recover the exact account reference expected on protocol operations. For upstream-managed account namespaces, `list_accounts` is not optional discovery polish; it is the namespace discovery contract. The upstream platform owns the accessible account set, so buyers MUST resolve an explicit `account_id` before the first account-scoped request. If the authenticated credential can access more than one account, the seller MUST expose `list_accounts`; if it can access exactly one account, the seller SHOULD expose `list_accounts` returning that singleton so SDKs can auto-select it and still send `{ "account_id": "..." }` on required-account calls. [`sync_accounts`](/docs/accounts/tasks/sync_accounts) provisioning does not create account-id accounts in 3.0.x unless a future explicit capability declares that mode; if `sync_accounts` is exposed on these sellers today, use it only for settings updates against an account already identified by `account_id`. @@ -65,11 +65,11 @@ asyncio.run(main()) All parameters are optional. An empty request returns all accounts visible to the authenticated caller. Use `account` when re-reading one known account by -`account_id` or by natural key (`brand` + `operator`, optionally `sandbox`). +seller-assigned `account_id` or by the complete buyer-declared natural key. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `account` | object | No | Exact account filter. Pass `{ "account_id": "..." }` after discovery, or a natural key with `brand`, `operator`, and optional `sandbox` for buyer-declared account sellers. The seller returns only matching accounts visible to the authenticated caller. | +| `account` | object | No | Exact account filter. Pass `{ "account_id": "..." }` after seller/storefront discovery, or the complete natural key (`brand`, `operator`, and optional `operator_unit`, `currency`, and `sandbox`) for buyer-declared accounts. The seller returns only matching accounts visible to the authenticated caller. | | `status` | string | No | Filter by account status: `active`, `pending_approval`, `rejected`, `payment_required`, `suspended`, or `closed`. | | `sandbox` | boolean | No | When true, return only sandbox accounts. When false, return only production accounts. Omit to return both. Primarily used with account-id namespaces where sandbox accounts are pre-existing test accounts on the platform. | | `include_webhook_activity` | boolean | No | When true, request recent webhook delivery attempts for each returned account in `webhook_activity[]`. | @@ -88,10 +88,12 @@ the authenticated caller. Use `account` when re-reading one known account by | Field | Description | |-------|-------------| -| `account_id` | Vendor agent's identifier. Pass this to protocol tasks: [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy), [`get_signals`](/docs/signals/tasks/get_signals), [`activate_signal`](/docs/signals/tasks/activate_signal), [`report_usage`](/docs/accounts/tasks/report_usage), and other operations. May be absent when `status: "rejected"`. | +| `account_id` | Seller/storefront identifier. Use it on protocol tasks when the seller declares an account-id namespace. It is distinct from `operator_unit.id`, which belongs to the buyer-side operator. May be absent when `status: "rejected"`. | | `name` | Vendor agent's display name for the account | -| `brand` | Brand reference object: `domain` (the [brand registry](/docs/brand-protocol/brand-json) house domain) and optional `brand_id` (sub-brand within the house) | +| `brand` | Brand reference: `domain`, optional `brand_id`, and optional commercial-identity `countries[]`. Countries are not delivery targeting. | | `operator` | Operator domain. Always present — when the brand operates directly, `operator` equals the brand's domain. | +| `operator_unit` | Optional operator-owned unit. `id` is stable identity; `name` is mutable display metadata. | +| `currency` | Optional immutable account currency. When present, media buys on the account must use it. | | `status` | Current account state: `active`, `pending_approval`, `rejected`, `payment_required`, `suspended`, or `closed` | | `billing` | Billing model in effect: `operator` or `agent` | | `account_scope` | How the seller scoped this account: `operator`, `brand`, `operator_brand`, or `agent`. See [account scope](/docs/building/by-layer/L2/accounts-and-agents#account-scope). | @@ -102,6 +104,8 @@ the authenticated caller. Use `account` when re-reading one known account by | `notification_configs` | Account-level webhook subscribers registered via [`sync_accounts`](/docs/accounts/tasks/sync_accounts#account-level-webhook-subscriptions). Each entry carries `subscriber_id`, `url`, `event_types[]`, and `active`. Present when the account has any persisted subscribers. `subscriber_id` is the account-scoped logical key; re-registering the same subscriber replaces that subscriber's config. `authentication.credentials` is omitted on every entry (write-only). Use this surface to verify what's active after a sync, audit fan-out across multiple subscribers, and detect drift between buyer-side expectations and seller-side persisted state. `account.status_changed` subscribers receive status invalidation fires and repair by re-reading this `status` field. | | `webhook_activity` | Optional recent webhook delivery attempts for this account, returned when `include_webhook_activity: true` and the seller exposes the debug log. Omitted means unsupported or not requested; `[]` means supported but no retained fires; non-empty records are most recent first. | +For buyer-declared accounts, `list_accounts` MUST return the natural-key fields needed to use the account again. A stateless buyer can therefore take `brand`, `operator`, `operator_unit`, `currency`, and `sandbox` from the response and send the same shape as `account` on a later task. `operator_unit.name` may change without changing which account the key identifies. + ### Single-publisher cardinality A seller serving exactly one publisher entity MAY return that entity as the sole account on `list_accounts` responses, regardless of calling principal. The *"Direct advertiser with single account"* example in [`list-accounts-response.json`](https://github.com/adcontextprotocol/adcp/blob/main/static/schemas/source/account/list-accounts-response.json) is canonical for this case — a single-element `accounts[]` with no `pagination` envelope at all. diff --git a/docs/accounts/tasks/sync_accounts.mdx b/docs/accounts/tasks/sync_accounts.mdx index 493c7c622b..58b0685fa8 100644 --- a/docs/accounts/tasks/sync_accounts.mdx +++ b/docs/accounts/tasks/sync_accounts.mdx @@ -5,7 +5,7 @@ description: "sync_accounts provisions buyer-declared accounts or updates settin testable: false --- -Sync advertiser accounts with a seller for one or more brand/operator relationships, or update settings on existing accounts when the seller exposes that mode. Brands use `domain` plus optional `brand_id` and ISO country `market`; `operator_region` can independently identify the operator's business region. +Sync advertiser accounts with a seller for one or more advertiser/operator relationships, or update settings on existing accounts when the seller exposes that mode. A buyer-declared account's natural key describes the advertiser object the seller should provision: a brand, the countries where that advertiser identity applies, the operator, an optional operator-owned unit, an optional fixed currency, and whether the account is a sandbox. `sync_accounts` is used across all seller protocols: media buy agents, signals agents, governance agents, and creative agents. In provisioning mode it declares the buyer's intent — the seller provisions or links accounts internally. Use provisioning mode for buyer-declared accounts (`require_operator_auth: false`) and use natural keys (`brand` + `operator`) on subsequent requests. Sellers MAY echo an `account_id` as an internal handle, but they MUST continue accepting the natural-key `AccountRef` for accounts provisioned this way. For account-id namespaces, discover seller-assigned account IDs via [`list_accounts`](/docs/accounts/tasks/list_accounts) or out-of-band onboarding; `sync_accounts` provisioning for account-id namespaces is out of scope unless a future explicit capability declares that mode. If such a seller exposes `sync_accounts` today, use only settings-update mode keyed by `account_id`. @@ -95,16 +95,36 @@ asyncio.run(main()) | Field | Type | Required | Description | |-------|------|----------|-------------| -| `brand` | object | Yes | Brand reference identifying the advertiser. Contains `domain`, optional `brand_id`, and optional ISO country `market`. See [brand-ref](/docs/brand-protocol/brand-json). | +| `brand` | object | Yes | Brand reference identifying the advertiser. Contains `domain`, optional `brand_id`, and optional ISO 3166-1 alpha-2 `countries[]`. Countries qualify the commercial advertiser identity; they do not target delivery. See [brand-ref](/docs/brand-protocol/brand-json). | | `operator` | string | Yes | Domain of the entity operating on the brand's behalf (e.g. `pinnacle-media.com`). When the brand operates directly, set to the brand's domain. Verified against the brand's `authorized_operators` in brand.json. | -| `operator_region` | string | No | Stable operator-defined business region such as `emea`. Part of the natural key; not delivery targeting. | +| `operator_unit` | object | No | Operator-owned business unit, agency seat, or buying-platform account. `id` is the stable operator-assigned identifier and participates in the natural key; optional `name` is mutable display metadata and does not. This is not the seller's `account_id`. | +| `currency` | string | No | Immutable ISO 4217 transaction currency when the seller's advertiser object is currency-bound. It participates in the natural key and every media buy on the account must use it. Omit it when the seller supports choosing currency per media buy. | | `billing` | string | Yes | Who should be invoiced: `operator`, `agent`, or `advertiser`. Check [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities) for `supported_billing` to see what the seller accepts at the capability level. The seller must either accept this billing model or reject the request. Sellers MAY additionally reject a value the seller-wide capability accepts when the calling buyer agent's commercial relationship does not permit it — e.g., a buyer agent onboarded as passthrough-only (no payments relationship — only the operator can be invoiced). The two gates use distinct error codes — [`BILLING_NOT_SUPPORTED`](/docs/building/verification/compliance-catalog#error-code-billing-not-supported) for the seller-wide capability gate, [`BILLING_NOT_PERMITTED_FOR_AGENT`](/docs/building/verification/compliance-catalog#error-code-billing-not-permitted-for-agent) for the per-buyer-agent gate — so agents can dispatch on autonomous-retry vs human-onboarding without parsing prose. See [Buyer-agent identity](/docs/building/by-layer/L2/accounts-and-agents#buyer-agent-identity) and [Billing and Account Setup](/docs/building/by-layer/L3/error-handling#billing-and-account-setup). | | `billing_entity` | object | No | Structured business entity details for the party responsible for payment. Contains `legal_name` (required), plus optional `vat_id`, `tax_id`, `registration_number`, `address`, `contacts`, and `bank`. Bank details are write-only — included in requests but never echoed in responses. See [billing entity and invoice recipient](/docs/building/by-layer/L2/accounts-and-agents#billing-entity-and-invoice-recipient). | | `payment_terms` | string | No | Payment terms for this account: `net_15`, `net_30`, `net_45`, `net_60`, `net_90`, or `prepay`. The seller must either accept these terms or reject the account — terms are never silently remapped. When omitted, the seller applies its default terms. | | `sandbox` | boolean | No | When true, set up a sandbox account with no real platform calls or billing. Only applicable to buyer-declared accounts (`require_operator_auth: false`). For account-id namespaces, sandbox accounts are pre-existing test accounts discovered via [`list_accounts`](/docs/accounts/tasks/list_accounts) or supplied out-of-band. | | `notification_configs` | array | No | Account-level webhook subscribers for events that outlive any single media buy: creative lifecycle notifications, account status changes, and wholesale feed change webhooks. Omit to leave existing subscribers unchanged; send `[]` to remove all subscribers; send a full array to replace. Entries are keyed by account-scoped `subscriber_id`; an existing `subscriber_id` is upserted, and persisted IDs absent from the sent array are removed. | -**Natural key**: The tuple `(brand, operator, operator_region, sandbox)` uniquely identifies an account relationship; `brand.market` is nested within the brand portion. `{brand: {domain: "nova-athletics.example", market: "NL"}, operator: "nova-athletics.example", operator_region: "emea"}` identifies the NL brand market managed by the EMEA operator unit. Adding `sandbox: true` provisions its separate sandbox account — no real platform calls or billing. +**Natural key**: The tuple `(brand, operator, operator_unit.id, currency, sandbox)` identifies the advertiser object in the seller system. The nested brand contains `domain`, optional `brand_id`, and optional `countries[]`. Country order has no semantic meaning; send unique uppercase codes in lexical order for stable keys and signatures. `operator_unit.name` is not identity, so renaming a seat does not create a new account. + +For example, the following key identifies Nova's NL/DE advertiser object, operated through Pinnacle's EMEA seat and fixed to EUR. Adding `sandbox: true` identifies a separate test account. + +```json +{ + "brand": { + "domain": "nova-athletics.example", + "countries": ["DE", "NL"] + }, + "operator": "pinnacle-media.example", + "operator_unit": { + "id": "seat_emea_01", + "name": "EMEA" + }, + "currency": "EUR" +} +``` + +The natural key is the buyer-declared account reference. A numeric or opaque `account_id` is instead a seller/storefront identifier: discover it with `list_accounts` (or receive it out of band) and do not substitute an operator's buying-platform account ID into that field. ## Response @@ -122,9 +142,10 @@ Returns an `accounts` array with per-account results. Individual accounts may be | Field | Description | |-------|-------------| -| `brand` | Echoed from request. Object with `domain` and optional `brand_id`/`market`. | +| `brand` | Echoed from request. Object with `domain`, optional `brand_id`, and optional `countries[]`. | | `operator` | Echoed from request. | -| `operator_region` | Echoed when supplied. | +| `operator_unit` | Echoed when supplied. `id` is stable identity; `name` is display metadata. | +| `currency` | Fixed account currency, echoed when supplied. | | `name` | Seller's display name for the account. | | `action` | What happened: `created`, `updated`, `unchanged`, or `failed`. | | `status` | Current state of the account (see [Account status](#account-status)). | diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index 847e43be04..ad55f208c5 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -407,7 +407,7 @@ A daypart with no declared semantics is ambiguous and MUST be rejected with `INV The compact tools reuse legacy implementation paths where useful but are distinct logical operations. A retry MUST keep the same tool name and payload. Reusing a key on another tool is a different payload and returns `IDEMPOTENCY_CONFLICT`; callers must not switch names after an ambiguous failure. -`request_proposals` binds each opaque proposal ID to its full BrandKey (`domain`, optional `brand_id`, and optional country `market`) and, when supplied, its seller account. A natural account's `operator_region` is part of that account binding, so two regional operator units serving the same brand market do not share an idempotency or proposal namespace. `refine_proposals` and `decline_proposals` resolve that binding before idempotency lookup, require every resolvable ID in a batch to share one binding, and scope the key to the resolved account or BrandKey. Callers do not repeat identity data on those mutation requests, and sellers MUST return the same not-found response for an absent proposal and a proposal outside the caller's principal/account scope. When no ID in a batch resolves, sellers use a principal-local negative-lookup scope; this permits safe replay of an all-`unable` result without creating a cross-account oracle. +`request_proposals` binds each opaque proposal ID to its full BrandKey (`domain`, optional `brand_id`, and optional `countries[]`) and, when supplied, its seller account. A natural account's operator-unit ID, fixed currency, and sandbox flag are part of that account binding, so distinct advertiser objects do not share an idempotency or proposal namespace. Country arrays are canonicalized as unique uppercase codes in lexical order; the operator unit's mutable display name is excluded from identity. `refine_proposals` and `decline_proposals` resolve that binding before idempotency lookup, require every resolvable ID in a batch to share one binding, and scope the key to the resolved account or BrandKey. Callers do not repeat identity data on those mutation requests, and sellers MUST return the same not-found response for an absent proposal and a proposal outside the caller's principal/account scope. When no ID in a batch resolves, sellers use a principal-local negative-lookup scope; this permits safe replay of an all-`unable` result without creating a cross-account oracle. This section applies only to AdCP task requests. OpenRTB bid streams have their own semantics (`BidRequest.id` is a transaction ID, not an idempotency key) and are out of scope. diff --git a/docs/building/by-layer/L2/accounts-and-agents.mdx b/docs/building/by-layer/L2/accounts-and-agents.mdx index 9b526281a8..6b23fb1a06 100644 --- a/docs/building/by-layer/L2/accounts-and-agents.mdx +++ b/docs/building/by-layer/L2/accounts-and-agents.mdx @@ -8,14 +8,14 @@ AdCP distinguishes four entities in every billable operation: | Entity | Question | How identified | |--------|----------|----------------| -| **Brand** | Whose products are advertised? | Brand reference: `domain` + optional `brand_id` and country `market` ([brand.json](/docs/brand-protocol/brand-json)) | +| **Brand** | Whose products are advertised? | Brand reference: `domain` + optional `brand_id` and commercial-identity `countries[]` ([brand.json](/docs/brand-protocol/brand-json)) | | **Account** | Who gets billed? What rates apply? | [Account reference](#account-references) | | **Operator** | Who operates on the brand's behalf? | Domain (e.g., `pinnacle-media.com`) | | **Agent** | What software is placing the buy? | Authenticated session | -**Brand** — The advertiser whose products or services are promoted. Identified by a `brand` reference (`domain` + optional `brand_id` and ISO country `market`), resolved via `/.well-known/brand.json`. `market` distinguishes local-market identities without becoming delivery targeting. +**Brand** — The advertiser whose products or services are promoted. Identified by a `brand` reference (`domain` + optional `brand_id` and ISO country `countries[]`), resolved via `/.well-known/brand.json`. Countries distinguish commercial advertiser identities without becoming delivery targeting. -**Account** — A billing relationship between a buyer and seller. Determines rate card, payment terms, credit limit, and who receives invoices. Every billable operation requires an account reference — a seller-assigned `account_id` when the seller or upstream platform owns the canonical account namespace, or a natural key (`brand`, `operator`, optional `operator_region`, `sandbox`) for buyer-declared accounts. +**Account** — A billing relationship between a buyer and seller. Determines rate card, payment terms, credit limit, and who receives invoices. Every billable operation requires an account reference — a seller/storefront-assigned `account_id` when the seller or upstream platform owns the canonical account namespace, or an advertiser natural key (`brand`, `operator`, optional `operator_unit`, `currency`, `sandbox`) for buyer-declared accounts. **Operator** — The entity driving buys — an agency trading desk, the brand's internal team, or another entity acting on behalf of the advertiser. Identified by domain and verifiable via [authorized operators](#authorized-operators) in `brand.json`. @@ -385,7 +385,7 @@ Some sellers use `account_id` without exposing an account discovery surface. In The agent manages the buying relationship. It calls `sync_accounts` to tell the seller who's advertising, who's operating on the brand's behalf, and who's paying. The seller provisions accounts and responds with status — the account IDs are a byproduct of the declaration, not something the buyer needs to know upfront. -Buyer-declared-account sellers SHOULD also expose `list_accounts` as the recovery read for stateless buyers. Replaying `sync_accounts` is not a cold-start recovery mechanism when the buyer has lost the `(brand, operator, operator_region, sandbox)` natural keys required to compose that replay. `list_accounts` returns the relationships the seller already acknowledged; it does not change the natural-key contract or make the seller-issued `account_id` mandatory on later calls. +Buyer-declared-account sellers SHOULD also expose `list_accounts` as the recovery read for stateless buyers. Replaying `sync_accounts` is not a cold-start recovery mechanism when the buyer has lost the advertiser natural keys required to compose that replay. `list_accounts` returns those acknowledged natural-key fields so they round-trip into later calls; it does not make the seller-issued `account_id` mandatory. **Typical sellers:** Traditional publishers, retail media networks, DSPs — anywhere the buying relationship is established programmatically. @@ -393,9 +393,10 @@ Buyer-declared-account sellers SHOULD also expose `list_accounts` as the recover | Flag | What it tells the seller | |------|-------------------------| -| `brand` (`domain` + optional `brand_id`/`market`) | Which brand and country market is advertising | +| `brand` (`domain` + optional `brand_id`/`countries[]`) | Which commercial advertiser entity is advertising; countries do not target delivery | | `operator` | Who operates on the brand's behalf (agency, trading desk, or the brand itself) | -| `operator_region` | Optional operator-defined business region | +| `operator_unit` | Optional operator-owned business unit, seat, or platform account; stable `id` plus mutable display `name` | +| `currency` | Optional immutable account currency when the seller's advertiser object is currency-bound | | `billing` | Who gets the invoice — `operator`, `agent`, or `advertiser` | | `billing_entity` | Structured business entity details for the party responsible for payment — legal name, VAT ID, tax ID, address, contacts, and bank details. Used for formal B2B invoicing. Bank details are write-only (never echoed in responses). | | `payment_terms` | Payment terms for this account (`net_15`, `net_30`, `net_45`, `net_60`, `net_90`, `prepay`). The seller must accept these terms or reject the account — terms are never silently remapped. | @@ -428,7 +429,9 @@ The agent may be directly responsible for billing when `billing` is `"agent"`. W ### Natural key semantics -The tuple `(brand, operator, operator_region, sandbox)` uniquely identifies an account relationship. The nested `brand` carries `domain`, optional `brand_id`, and optional ISO country `market`; `operator_region` is an optional operator-defined ID such as `emea`. Both qualifiers participate in identity and neither targets delivery. For example, `{brand: {domain: "nova-athletics.example", market: "NL"}, operator: "nova-athletics.example", operator_region: "emea"}` identifies the NL brand market under its EMEA operator unit. +The tuple `(brand, operator, operator_unit.id, currency, sandbox)` identifies the advertiser object in the seller system. The nested `brand` carries `domain`, optional `brand_id`, and optional ISO country `countries[]`; these countries qualify commercial identity and never target delivery. `operator_unit` identifies the operator's own business unit, agency seat, or buying-platform account. Its stable `id` participates in identity, while its optional `name` is display metadata and may change without creating a new account. `currency` participates only when the seller's advertiser object is locked to one currency. Omit it when currency is selected per media buy. + +This separates two account namespaces that must not be conflated: `operator_unit.id` is assigned by the buyer-side operator, while `account_id` is assigned by the AdCP seller/storefront. Both may be opaque values, but they identify different objects and are not interchangeable. See [sync_accounts task reference](/docs/accounts/tasks/sync_accounts) for the full request/response schema. @@ -458,7 +461,7 @@ The agent does not choose the scope — the seller assigns it based on its own a When multiple natural keys resolve to the same scope, the `account_scope` explains why. -For buyer-declared accounts (`require_operator_auth: false`), use the complete natural key (`brand` + `operator` + optional `operator_region`) on subsequent requests — adding `sandbox: true` for sandbox accounts. A seller may also return an `account_id` from `sync_accounts` as its internal handle, but the seller MUST continue accepting the natural-key `AccountRef` for every account provisioned this way. For account-id namespaces (`require_operator_auth: true`), discover account IDs via `list_accounts` for upstream-managed namespaces, or receive them out-of-band for seller-defined namespaces — including sandbox test accounts. +For buyer-declared accounts (`require_operator_auth: false`), use the complete natural key (`brand` + `operator` + optional `operator_unit`, `currency`, and `sandbox`) on subsequent requests. A seller may also return an `account_id` from `sync_accounts` as its internal handle, but the seller MUST continue accepting the natural-key `AccountRef` for every account provisioned this way and MUST return its natural-key fields from `list_accounts`. For account-id namespaces (`require_operator_auth: true`), discover seller/storefront account IDs via `list_accounts` for upstream-managed namespaces, or receive them out-of-band for seller-defined namespaces — including sandbox test accounts. ## Error codes diff --git a/docs/media-buy/task-reference/request_proposals.mdx b/docs/media-buy/task-reference/request_proposals.mdx index 80e0be7041..382b2e7630 100644 --- a/docs/media-buy/task-reference/request_proposals.mdx +++ b/docs/media-buy/task-reference/request_proposals.mdx @@ -7,7 +7,7 @@ testable: false `request_proposals` creates one or more immutable draft media-plan proposal snapshots from a brief. It can begin a consultative workflow directly or use `product_ids` returned by [`list_products`](/docs/media-buy/task-reference/list_products). Success always contains at least one proposal; returning products without a proposal does not satisfy this task. -`brand` contains only the stable BrandKey (`domain`, optional `brand_id`, and optional ISO country `market`); the seller resolves the canonical brand manifest rather than receiving a full brand file in every call. `market` qualifies identity and does not target delivery. `account` is optional and adds seller-specific commercial terms. A natural-key account can supply the request's sole brand identity and independently qualify the operator with `operator_region` — for example, an NL-market brand managed by the operator's EMEA business unit. +`brand` contains only the stable BrandKey (`domain`, optional `brand_id`, and optional ISO country `countries[]`); the seller resolves the canonical brand manifest rather than receiving a full brand file in every call. Countries qualify commercial advertiser identity and do not target delivery. `account` is optional and adds seller-specific commercial terms. A natural-key account can supply the request's sole brand identity and qualify it with an operator-owned unit and fixed account currency. **Request schema:** [`/schemas/v3/media-buy/request-proposals-request.json`](https://adcontextprotocol.org/schemas/v3/media-buy/request-proposals-request.json) @@ -16,9 +16,16 @@ testable: false "idempotency_key": "550e8400-e29b-41d4-a716-446655441001", "brief": "Reach outdoor enthusiasts with premium video inventory.", "account": { - "brand": { "domain": "nova-athletics.example", "market": "NL" }, - "operator": "nova-athletics.example", - "operator_region": "emea" + "brand": { + "domain": "nova-athletics.example", + "countries": ["DE", "NL"] + }, + "operator": "pinnacle-media.example", + "operator_unit": { + "id": "seat_emea_01", + "name": "EMEA" + }, + "currency": "EUR" }, "opportunity": { "opportunity_id": "opp_spring_launch_2027", diff --git a/docs/protocol/calling-an-agent.mdx b/docs/protocol/calling-an-agent.mdx index 74b5305a8f..4ad9a6981c 100644 --- a/docs/protocol/calling-an-agent.mdx +++ b/docs/protocol/calling-an-agent.mdx @@ -46,7 +46,9 @@ Missing the key → `adcp_error.code: 'VALIDATION_ERROR'` with `/idempotency_key // buyer-declared sellers may also echo account_id from sync_accounts as an internal handle) "account": { "account_id": "seller_assigned_id" } -// variant 1: by natural key (brand + operator, optional market/operator_region/sandbox) +// variant 1: by advertiser natural key +// (brand + operator, optional operator_unit/currency/sandbox; +// brand may include commercial-identity countries[]) // brand.domain — the buyer's brand domain (e.g., advertiser website) // operator — the buyer-side entity operating on behalf of the brand "account": { "brand": { "domain": "acme.com" }, "operator": "pinnacle-media.com" } diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index 13bdca2b67..73f7c642db 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -344,7 +344,7 @@ Account and authentication capabilities. All sellers should declare this section | Field | Type | Description | |-------|------|-------------| | `supported_billing` | string[] | **Required.** Billing models this seller supports: `operator`, `agent`, or `advertiser`. When the buyer calls `sync_accounts`, every entry must select one of these values. | -| `require_operator_auth` | boolean | Default: `false`. Declares who must authenticate; it does not by itself declare whether OAuth is used, whether `list_accounts` is exposed, or which `sync_accounts` modes are supported. When `true`, each operator authenticates independently and account-scoped calls use seller-assigned `account_id` values because the seller or upstream platform owns the canonical account namespace. If a credential may access more than one account, the seller MUST expose `list_accounts` and buyers MUST resolve an explicit `account_id` before the first account-scoped request. If a credential is bound to exactly one account, the seller SHOULD expose `list_accounts` returning that singleton; a seller MAY omit `list_accounts` only when it provides the same explicit `account_id` through another declared path or out-of-band onboarding. When `false`, the agent is trusted and subsequent calls pass the natural key (`brand` + `operator`). The seller normally provisions that relationship through `sync_accounts`, but MAY lazily provision it on the first account-scoped request when billing and other required settings are unambiguous from capabilities or onboarding defaults. A lazy-provisioning seller MUST continue accepting the natural key and MUST expose `list_accounts` for recovery; if the buyer must choose billing, terms, sandbox setup, notifications, or other settings before use, the seller MUST expose `sync_accounts`. | +| `require_operator_auth` | boolean | Default: `false`. Declares who must authenticate; it does not by itself declare whether OAuth is used, whether `list_accounts` is exposed, or which `sync_accounts` modes are supported. When `true`, each operator authenticates independently and account-scoped calls use seller/storefront-assigned `account_id` values. When `false`, the agent is trusted and calls use the advertiser natural key: `brand` + `operator` + optional `operator_unit`, fixed `currency`, and `sandbox`. `operator_unit.id` belongs to the operator and is not the seller's `account_id`. Buyer-declared sellers normally provision through `sync_accounts`, MUST continue accepting the natural key, and SHOULD expose `list_accounts` so all key fields can be recovered after a cold start. | | `authorization_endpoint` | string | OAuth URL for operator authentication. Present when the seller supports OAuth for operator authentication. Relevant when `require_operator_auth: true`; if absent, operators obtain credentials out-of-band (seller portal, API key). | | `required_for_products` | boolean | Default: `false`. When `true`, the buyer must establish an account before calling [`get_products`](/docs/media-buy/task-reference/get_products). When `false`, the buyer can browse products without an account — useful for price comparison and discovery before committing to a seller. | | `account_financials` | boolean | Default: `false`. When `true`, the seller supports [`get_account_financials`](/docs/accounts/tasks/get_account_financials) for querying spend, credit, and invoice status. Only applicable to operator-billed accounts. | @@ -376,7 +376,7 @@ See [Provision a seller-mediated account](/docs/accounts/provisioning-walkthroug #### Auth models -**Buyer-declared accounts** (`require_operator_auth: false`) — The seller trusts the agent's identity claims. The agent authenticates once with its own bearer token, and all account-scoped calls use that credential plus a natural key (`brand` + `operator`). Most sellers expose `sync_accounts` so the buyer can declare the relationship and select billing or other settings before use. A seller MAY instead auto-provision on the first account-scoped request when those settings are unambiguous from capabilities or onboarding defaults; in that mode it MUST expose `list_accounts` as the recovery read and MUST keep accepting the natural key. Auto-provisioning is not conformant when buyer input is needed to resolve billing, terms, sandbox setup, notifications, or other account settings before the operation. +**Buyer-declared accounts** (`require_operator_auth: false`) — The seller trusts the agent's identity claims. The agent authenticates once with its own bearer token, and account-scoped calls use that credential plus the advertiser natural key (`brand` + `operator` + optional `operator_unit`, fixed `currency`, and `sandbox`). `brand.countries[]` may qualify the commercial advertiser identity without targeting delivery. Most sellers expose `sync_accounts` so the buyer can declare the relationship and select billing or other settings before use. A seller MAY instead auto-provision on the first account-scoped request when those settings are unambiguous from capabilities or onboarding defaults; in that mode it MUST expose `list_accounts` as the recovery read and MUST keep accepting the natural key. Auto-provisioning is not conformant when buyer input is needed to resolve billing, terms, sandbox setup, notifications, or other account settings before the operation. **Account-id namespaces** (`require_operator_auth: true`) — Each operator must authenticate with the seller directly. The agent obtains a credential per operator — via OAuth using `authorization_endpoint`, or out-of-band — opens a per-operator session, and passes seller-assigned `account_id` values on subsequent requests. OAuth is credential acquisition, not an account taxonomy axis. Two namespace patterns use the same wire reference: upstream-managed sellers expose `list_accounts`, making explicit account resolution mandatory before account-scoped calls; seller-defined namespaces without `list_accounts` provide account IDs out-of-band. SDKs SHOULD lazily call `list_accounts` when first needed, auto-select a singleton, cache it per credential/session, and still send explicit `AccountRef` values on required-account calls. diff --git a/server/src/training-agent/account-handlers.ts b/server/src/training-agent/account-handlers.ts index 9c83e03a6b..12fc44004d 100644 --- a/server/src/training-agent/account-handlers.ts +++ b/server/src/training-agent/account-handlers.ts @@ -6,7 +6,8 @@ */ import { randomUUID } from 'node:crypto'; -import type { TrainingContext, ToolArgs, AccountRef } from './types.js'; +import type { TrainingContext, ToolArgs, AccountRef, OperatorUnit } from './types.js'; +import { accountScopeFromRef } from './account-scope.js'; import { sessionKeyFromArgs } from './state.js'; import { getAgentUrl } from './config.js'; import { encodeOffsetCursor, decodeOffsetCursor } from './pagination.js'; @@ -38,9 +39,10 @@ interface SyncAccountsInput extends ToolArgs { interface SyncAccountInput { account?: AccountRef; - brand?: { domain: string; brand_id?: string; market?: string; name?: string }; + brand?: { domain: string; brand_id?: string; countries?: string[]; name?: string }; operator?: string; - operator_region?: string; + operator_unit?: OperatorUnit; + currency?: string; billing?: 'operator' | 'agent' | 'advertiser'; billing_entity?: Record; payment_terms?: string; @@ -50,9 +52,10 @@ interface SyncAccountInput { interface AccountState { accountId: string; - brand: { domain: string; brand_id?: string; market?: string; name?: string }; + brand: { domain: string; brand_id?: string; countries?: string[]; name?: string }; operator: string; - operatorRegion?: string; + operatorUnit?: OperatorUnit; + currency?: string; billing: string; billingEntity?: Record; paymentTerms: string; @@ -134,13 +137,23 @@ function getAccountMap(sessionKey: string, principal?: string): Map, ref: AccountRef): AccountState | undefined { @@ -151,7 +164,13 @@ function findAccountByRef(accounts: Map, ref: AccountRef): return undefined; } if (ref.brand?.domain && ref.operator) { - return accounts.get(accountKey(ref.brand, ref.operator, ref.operator_region, ref.sandbox === true)); + return accounts.get(accountKey( + ref.brand, + ref.operator, + ref.operator_unit, + ref.currency, + ref.sandbox === true, + )); } return undefined; } @@ -187,10 +206,11 @@ export function sandboxAccountRefForId( brand: { domain: account.brand.domain.toLowerCase(), ...(account.brand.brand_id && { brand_id: account.brand.brand_id }), - ...(account.brand.market && { market: account.brand.market }), + ...(account.brand.countries && { countries: [...account.brand.countries] }), }, operator: account.operator.toLowerCase(), - ...(account.operatorRegion && { operator_region: account.operatorRegion }), + ...(account.operatorUnit && { operator_unit: { ...account.operatorUnit } }), + ...(account.currency && { currency: account.currency }), sandbox: true, }; } @@ -224,7 +244,8 @@ function accountStateFromWire(wire: AccountWireShape, now: string): AccountState accountId: wire.account_id, brand: wire.brand, operator: wire.operator, - operatorRegion: wire.operator_region, + operatorUnit: wire.operator_unit, + currency: wire.currency, billing: wire.billing, paymentTerms: wire.payment_terms ?? 'net_30', status: wire.status, @@ -253,11 +274,12 @@ interface AccountWireShape { account_id: string; name: string; advertiser: string; - // brand-ref.json defines this object; it carries domain + optional brand_id/market + // brand-ref.json defines this object; it carries domain + optional brand_id/countries // only — `name` is not in the schema and additionalProperties is false. - brand: { domain: string; brand_id?: string; market?: string }; + brand: { domain: string; brand_id?: string; countries?: string[] }; operator: string; - operator_region?: string; + operator_unit?: OperatorUnit; + currency?: string; billing: string; account_scope: string; status: string; @@ -271,22 +293,23 @@ interface AccountWireShape { function accountStateToWire(account: AccountState): AccountWireShape { const advertiser = account.brand.name ?? account.brand.domain; const displayName = account.brand.domain === account.operator - ? advertiser - : `${advertiser} c/o ${account.operator}`; + ? account.operatorUnit?.name ? `${advertiser} — ${account.operatorUnit.name}` : advertiser + : `${advertiser} c/o ${account.operatorUnit?.name ?? account.operator}`; // brand-ref.json forbids `name` on the wire (additionalProperties: false), // so emit only the schema-declared fields. AccountState.brand.name is an // operational hint we use to derive `name`/`advertiser` above; it never // reaches the buyer. - const wireBrand: { domain: string; brand_id?: string; market?: string } = { domain: account.brand.domain }; + const wireBrand: { domain: string; brand_id?: string; countries?: string[] } = { domain: account.brand.domain }; if (account.brand.brand_id !== undefined) wireBrand.brand_id = account.brand.brand_id; - if (account.brand.market !== undefined) wireBrand.market = account.brand.market; + if (account.brand.countries !== undefined) wireBrand.countries = [...account.brand.countries]; const wire: AccountWireShape = { account_id: account.accountId, name: displayName, advertiser, brand: wireBrand, operator: account.operator, - ...(account.operatorRegion && { operator_region: account.operatorRegion }), + ...(account.operatorUnit && { operator_unit: { ...account.operatorUnit } }), + ...(account.currency && { currency: account.currency }), billing: account.billing, account_scope: account.accountScope, status: account.status, @@ -335,7 +358,12 @@ function validationFailure(input: SyncAccountInput, field: string, message: stri return { ...(input.account ? { account: input.account } - : { brand: input.brand, operator: input.operator, ...(input.operator_region && { operator_region: input.operator_region }) }), + : { + brand: input.brand, + operator: input.operator, + ...(input.operator_unit && { operator_unit: input.operator_unit }), + ...(input.currency && { currency: input.currency }), + }), action: 'failed', status: 'rejected', errors: [{ code: 'VALIDATION_ERROR', field, message }], @@ -536,9 +564,16 @@ export function getAccountNotificationSubscribers( && accountKey( accountRef!.brand!, accountRef!.operator!, - accountRef!.operator_region, + accountRef!.operator_unit, + accountRef!.currency, accountRef!.sandbox === true, - ) !== accountKey(account.brand, account.operator, account.operatorRegion, account.sandbox) + ) !== accountKey( + account.brand, + account.operator, + account.operatorUnit, + account.currency, + account.sandbox, + ) ) continue; for (const config of account.notificationConfigs) { if (!config.active || !config.eventTypes.includes(notificationType)) continue; @@ -573,6 +608,23 @@ export function resolveAccountIdForRef( : undefined; } +/** Resolve the immutable currency of a currency-bound advertiser account. */ +export function resolveAccountCurrencyForRef( + sessionKey: string, + principal: string | undefined, + ref: AccountRef | undefined, +): string | undefined { + if (!ref) return undefined; + if (ref.currency) return ref.currency; + for (const accounts of accountMapsForPrincipal(sessionKey, principal)) { + const account = findAccountByRef(accounts, ref); + if (account) return account.currency; + } + return ref.account_id + ? findAccountByIdAcrossSessions(ref.account_id, principal)?.currency + : undefined; +} + export function resolveGovernanceAgentsForAccount( sessionKey: string, principal: string | undefined, @@ -610,9 +662,12 @@ export function seedAccountFixture( } const fixture = (params.fixture ?? {}) as Record; - const brand = fixture.brand as { domain?: string; brand_id?: string; market?: string; name?: string } | undefined; + const brand = fixture.brand as { domain?: string; brand_id?: string; countries?: string[]; name?: string } | undefined; const operator = fixture.operator; - const operatorRegion = typeof fixture.operator_region === 'string' ? fixture.operator_region : undefined; + const operatorUnit = fixture.operator_unit && typeof fixture.operator_unit === 'object' + ? fixture.operator_unit as unknown as OperatorUnit + : undefined; + const currency = typeof fixture.currency === 'string' ? fixture.currency : undefined; if (!brand?.domain) { return { success: false, error: 'INVALID_PARAMS', error_detail: 'params.fixture.brand.domain is required for seed_account' }; } @@ -627,9 +682,10 @@ export function seedAccountFixture( const accounts = getAccountMap(sessionKey, ctx.principal); const sandbox = fixture.sandbox !== false; const key = accountKey( - brand as { domain: string; brand_id?: string; market?: string }, + brand as { domain: string; brand_id?: string; countries?: string[] }, operator, - operatorRegion, + operatorUnit, + currency, sandbox, ); const existing = accounts.get(key) @@ -637,9 +693,10 @@ export function seedAccountFixture( const state: AccountState = { accountId, - brand: brand as { domain: string; brand_id?: string; market?: string; name?: string }, + brand: brand as { domain: string; brand_id?: string; countries?: string[]; name?: string }, operator, - operatorRegion, + operatorUnit, + currency, billing, paymentTerms: typeof fixture.payment_terms === 'string' ? fixture.payment_terms : 'net_30', status, @@ -727,13 +784,27 @@ export const ACCOUNT_REF_SCHEMA = { properties: { domain: { type: 'string' }, brand_id: { type: 'string' }, - market: { type: 'string', pattern: '^[A-Z]{2}$' }, + countries: { + type: 'array', + items: { type: 'string', pattern: '^[A-Z]{2}$' }, + minItems: 1, + uniqueItems: true, + }, }, required: ['domain'], additionalProperties: false, }, operator: { type: 'string' }, - operator_region: { type: 'string', minLength: 1, maxLength: 64, pattern: '^[a-z0-9][a-z0-9_-]*$' }, + operator_unit: { + type: 'object', + properties: { + id: { type: 'string', minLength: 1, maxLength: 255, pattern: '^[A-Za-z0-9][A-Za-z0-9._:/-]*$' }, + name: { type: 'string', minLength: 1, maxLength: 200 }, + }, + required: ['id'], + additionalProperties: false, + }, + currency: { type: 'string', pattern: '^[A-Z]{3}$' }, sandbox: { type: 'boolean' }, }, required: ['brand', 'operator'], @@ -793,14 +864,28 @@ export const ACCOUNT_TOOLS = [ properties: { domain: { type: 'string' }, brand_id: { type: 'string' }, - market: { type: 'string', pattern: '^[A-Z]{2}$' }, + countries: { + type: 'array', + items: { type: 'string', pattern: '^[A-Z]{2}$' }, + minItems: 1, + uniqueItems: true, + }, name: { type: 'string' }, }, required: ['domain'], }, account: ACCOUNT_REF_SCHEMA, operator: { type: 'string' }, - operator_region: { type: 'string', minLength: 1, maxLength: 64, pattern: '^[a-z0-9][a-z0-9_-]*$' }, + operator_unit: { + type: 'object', + properties: { + id: { type: 'string', minLength: 1, maxLength: 255, pattern: '^[A-Za-z0-9][A-Za-z0-9._:/-]*$' }, + name: { type: 'string', minLength: 1, maxLength: 200 }, + }, + required: ['id'], + additionalProperties: false, + }, + currency: { type: 'string', pattern: '^[A-Z]{3}$' }, billing: { type: 'string', enum: ['operator', 'agent', 'advertiser'] }, billing_entity: { type: 'object' }, payment_terms: { type: 'string', enum: ['net_15', 'net_30', 'net_45', 'net_60', 'net_90', 'prepay'] }, @@ -838,7 +923,8 @@ export const ACCOUNT_TOOLS = [ allOf: [ { not: { required: ['brand'] } }, { not: { required: ['operator'] } }, - { not: { required: ['operator_region'] } }, + { not: { required: ['operator_unit'] } }, + { not: { required: ['currency'] } }, { not: { required: ['billing'] } }, ], }, @@ -948,7 +1034,8 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { const mixedProvisioningFields = [ input.brand !== undefined && 'brand', input.operator !== undefined && 'operator', - input.operator_region !== undefined && 'operator_region', + input.operator_unit !== undefined && 'operator_unit', + input.currency !== undefined && 'currency', input.billing !== undefined && 'billing', input.sandbox !== undefined && 'sandbox', ].filter(Boolean); @@ -985,7 +1072,8 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { accounts.set(accountKey( existing.brand, existing.operator, - existing.operatorRegion, + existing.operatorUnit, + existing.currency, existing.sandbox, ), existing); } @@ -1023,7 +1111,8 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { account: input.account, brand: existing.brand, operator: existing.operator, - ...(existing.operatorRegion && { operator_region: existing.operatorRegion }), + ...(existing.operatorUnit && { operator_unit: existing.operatorUnit }), + ...(existing.currency && { currency: existing.currency }), action: 'updated', status: existing.status, billing: existing.billing, @@ -1168,7 +1257,13 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { continue; } - const key = accountKey(input.brand, input.operator, input.operator_region, input.sandbox === true); + const key = accountKey( + input.brand, + input.operator, + input.operator_unit, + input.currency, + input.sandbox === true, + ); const existing = accounts.get(key); const isSandbox = input.sandbox === true; const accountId = existing?.accountId || `acc_${input.brand.domain.replace(/\./g, '_')}_${randomUUID().slice(0, 8)}`; @@ -1187,7 +1282,8 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { results.push({ brand: input.brand, operator: input.operator, - ...(input.operator_region && { operator_region: input.operator_region }), + ...(input.operator_unit && { operator_unit: input.operator_unit }), + ...(input.currency && { currency: input.currency }), action: existing ? 'updated' : 'created', status: isSandbox ? 'active' : 'pending_approval', billing: input.billing, @@ -1213,7 +1309,8 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { accountId, brand: input.brand, operator: input.operator, - operatorRegion: input.operator_region, + operatorUnit: input.operator_unit, + currency: input.currency, billing: input.billing!, billingEntity: input.billing_entity, paymentTerms: input.payment_terms || 'net_30', @@ -1221,7 +1318,7 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { accountScope: 'operator_brand', sandbox: isSandbox, rateCard: isSandbox ? 'sandbox' : 'standard', - creditLimit: isSandbox ? undefined : { amount: 100000, currency: 'USD' }, + creditLimit: isSandbox ? undefined : { amount: 100000, currency: input.currency ?? 'USD' }, governanceAgents: existing?.governanceAgents || [], notificationConfigs: Array.isArray(notificationConfigs) ? notificationConfigs @@ -1238,8 +1335,11 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { account_id: accountId, brand: input.brand, operator: input.operator, - ...(input.operator_region && { operator_region: input.operator_region }), - name: `${input.brand.name || input.brand.domain} (via ${input.operator})`, + ...(input.operator_unit && { operator_unit: input.operator_unit }), + ...(input.currency && { currency: input.currency }), + name: input.operator_unit?.name + ? `${input.brand.name || input.brand.domain} (via ${input.operator_unit.name})` + : `${input.brand.name || input.brand.domain} (via ${input.operator})`, action, status, billing: input.billing, @@ -1295,9 +1395,13 @@ function wireAccountMatchesRef(account: AccountWireShape, ref: AccountRef): bool if (!ref.brand?.domain || !ref.operator) return false; if (account.brand.domain !== ref.brand.domain) return false; if (ref.brand.brand_id !== undefined && account.brand.brand_id !== ref.brand.brand_id) return false; - if ((account.brand.market ?? undefined) !== (ref.brand.market ?? undefined)) return false; + if ( + JSON.stringify([...(account.brand.countries ?? [])].sort()) + !== JSON.stringify([...(ref.brand.countries ?? [])].sort()) + ) return false; if (account.operator !== ref.operator) return false; - if ((account.operator_region ?? undefined) !== (ref.operator_region ?? undefined)) return false; + if ((account.operator_unit?.id ?? undefined) !== (ref.operator_unit?.id ?? undefined)) return false; + if ((account.currency ?? undefined) !== (ref.currency ?? undefined)) return false; if (typeof ref.sandbox === 'boolean') return (account.sandbox === true) === ref.sandbox; return true; } diff --git a/server/src/training-agent/account-scope.ts b/server/src/training-agent/account-scope.ts index c867521192..1f872dc803 100644 --- a/server/src/training-agent/account-scope.ts +++ b/server/src/training-agent/account-scope.ts @@ -1,21 +1,32 @@ -import type { AccountRef } from './types.js'; +import type { AccountRef, OperatorUnit } from './types.js'; const ACCOUNT_ID_KEYS = new Set(['account_id']); -const NATURAL_ACCOUNT_KEYS = new Set(['brand', 'operator', 'operator_region', 'sandbox']); +const NATURAL_ACCOUNT_KEYS = new Set(['brand', 'operator', 'operator_unit', 'currency', 'sandbox']); +const BRAND_REF_KEYS = new Set([ + 'domain', + 'brand_id', + 'countries', + 'industries', + 'data_subject_contestation', + 'brand_kit_override', +]); const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i; const BRAND_ID_RE = /^[a-z0-9_]+$/; -const MARKET_RE = /^[A-Z]{2}$/; -const OPERATOR_REGION_RE = /^[a-z0-9][a-z0-9_-]*$/; +const COUNTRY_RE = /^[A-Z]{2}$/; +const CURRENCY_RE = /^[A-Z]{3}$/; +const OPERATOR_UNIT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/; const MAX_DOMAIN_LENGTH = 253; -const MAX_OPERATOR_REGION_LENGTH = 64; +const MAX_OPERATOR_UNIT_ID_LENGTH = 255; +const MAX_OPERATOR_UNIT_NAME_LENGTH = 200; export type CanonicalAccountRef = | { kind: 'account_id'; account_id: string } | { kind: 'natural'; - brand: { domain: string; brand_id?: string; market?: string }; + brand: { domain: string; brand_id?: string; countries?: string[] }; operator: string; - operator_region?: string; + operator_unit?: OperatorUnit; + currency?: string; sandbox: boolean; }; @@ -52,7 +63,8 @@ function normalizedDomain(value: unknown, field: string): string { * Validate AccountRef's exact-oneOf identity and return only canonical fields. * * AccountRef is a closed union: either the sole `account_id` field, or the - * natural `brand` + `operator` identity with optional `sandbox`. Checking + * natural advertiser identity with optional operator unit, fixed currency, + * and sandbox disposition. Checking * property presence (rather than truthiness) ensures mixed, incomplete, and * unknown shapes cannot be silently interpreted as a different account. */ @@ -87,6 +99,8 @@ export function canonicalizeAccountRef(value: unknown): CanonicalAccountRef { invalid('account must use exactly one identity: account_id or brand + operator.'); } if (!isRecord(value.brand)) invalid('account.brand must be an object.'); + const unknownBrandKey = Object.keys(value.brand).find(key => !BRAND_REF_KEYS.has(key)); + if (unknownBrandKey) invalid(`account.brand does not allow field '${unknownBrandKey}'.`); const domain = normalizedDomain(value.brand.domain, 'account.brand.domain'); const operator = normalizedDomain(value.operator, 'account.operator'); @@ -97,23 +111,52 @@ export function canonicalizeAccountRef(value: unknown): CanonicalAccountRef { } brandId = value.brand.brand_id; } - let market: string | undefined; - if (Object.prototype.hasOwnProperty.call(value.brand, 'market')) { - if (typeof value.brand.market !== 'string' || !MARKET_RE.test(value.brand.market)) { - invalid('account.brand.market must be an ISO 3166-1 alpha-2 country code.'); + let countries: string[] | undefined; + if (Object.prototype.hasOwnProperty.call(value.brand, 'countries')) { + if ( + !Array.isArray(value.brand.countries) + || value.brand.countries.length === 0 + || value.brand.countries.some(country => typeof country !== 'string' || !COUNTRY_RE.test(country)) + || new Set(value.brand.countries).size !== value.brand.countries.length + ) { + invalid('account.brand.countries must be a non-empty unique array of ISO 3166-1 alpha-2 country codes.'); } - market = value.brand.market; + countries = [...value.brand.countries].sort(); } - let operatorRegion: string | undefined; - if (Object.prototype.hasOwnProperty.call(value, 'operator_region')) { + let operatorUnit: OperatorUnit | undefined; + if (Object.prototype.hasOwnProperty.call(value, 'operator_unit')) { + if (!isRecord(value.operator_unit)) invalid('account.operator_unit must be an object.'); + const unitKeys = Object.keys(value.operator_unit); + const unknownUnitKey = unitKeys.find(key => key !== 'id' && key !== 'name'); + if (unknownUnitKey) invalid(`account.operator_unit does not allow field '${unknownUnitKey}'.`); + if ( + typeof value.operator_unit.id !== 'string' + || value.operator_unit.id.length > MAX_OPERATOR_UNIT_ID_LENGTH + || !OPERATOR_UNIT_ID_RE.test(value.operator_unit.id) + ) { + invalid('account.operator_unit.id must be a stable operator-defined identifier.'); + } if ( - typeof value.operator_region !== 'string' - || value.operator_region.length > MAX_OPERATOR_REGION_LENGTH - || !OPERATOR_REGION_RE.test(value.operator_region) + value.operator_unit.name !== undefined + && ( + typeof value.operator_unit.name !== 'string' + || value.operator_unit.name.length === 0 + || value.operator_unit.name.length > MAX_OPERATOR_UNIT_NAME_LENGTH + ) ) { - invalid('account.operator_region must be a lowercase operator-defined region identifier.'); + invalid('account.operator_unit.name must be a non-empty string of at most 200 characters.'); + } + operatorUnit = { + id: value.operator_unit.id, + ...(typeof value.operator_unit.name === 'string' && { name: value.operator_unit.name }), + }; + } + let currency: string | undefined; + if (Object.prototype.hasOwnProperty.call(value, 'currency')) { + if (typeof value.currency !== 'string' || !CURRENCY_RE.test(value.currency)) { + invalid('account.currency must be an ISO 4217 currency code.'); } - operatorRegion = value.operator_region; + currency = value.currency; } if (value.sandbox !== undefined && typeof value.sandbox !== 'boolean') { invalid('account.sandbox must be a boolean when provided.'); @@ -124,10 +167,11 @@ export function canonicalizeAccountRef(value: unknown): CanonicalAccountRef { brand: { domain, ...(brandId !== undefined && { brand_id: brandId }), - ...(market !== undefined && { market }), + ...(countries !== undefined && { countries }), }, operator, - ...(operatorRegion !== undefined && { operator_region: operatorRegion }), + ...(operatorUnit !== undefined && { operator_unit: operatorUnit }), + ...(currency !== undefined && { currency }), sandbox: value.sandbox ?? false, }; } @@ -136,19 +180,11 @@ export function canonicalizeAccountRef(value: unknown): CanonicalAccountRef { export function accountScopeFromRef(value: AccountRef | unknown): string { const account = canonicalizeAccountRef(value); if (account.kind === 'account_id') return `a:${account.account_id}`; - const base = [ - 'n', - account.brand.domain, - account.brand.brand_id ?? '-', - account.operator, - account.sandbox ? '1' : '0', - ].join(':'); - if (account.brand.market === undefined && account.operator_region === undefined) return base; - return [ - base, - 'm', - account.brand.market ?? '-', - 'r', - account.operator_region ?? '-', - ].join(':'); + return `n:${JSON.stringify({ + brand: account.brand, + operator: account.operator, + operator_unit_id: account.operator_unit?.id ?? null, + currency: account.currency ?? null, + sandbox: account.sandbox, + })}`; } diff --git a/server/src/training-agent/source-schema.ts b/server/src/training-agent/source-schema.ts index 18dd70d312..0f91e8d6b4 100644 --- a/server/src/training-agent/source-schema.ts +++ b/server/src/training-agent/source-schema.ts @@ -168,7 +168,19 @@ function bundleSchema(root: JsonSchema): JsonSchema { /** Load the normative source request schema and bundle every repository-local * reference for MCP tools/list consumers, which cannot resolve AdCP paths. */ export function loadProductDiscoveryInputSchema(fileName: string): JsonSchema { - return bundleSchema(readSchema(`media-buy/${fileName}.json`)); + const bundled = bundleSchema(readSchema(`media-buy/${fileName}.json`)); + const { + $schema: _schema, + $id: _id, + title: _title, + description: _description, + 'x-operation-family': _operationFamily, + 'x-added-in': _addedIn, + 'x-legacy-fallback': _legacyFallback, + 'x-mutates-state': _mutatesState, + ...inputSchema + } = bundled; + return inputSchema; } function schemaFiles(directory: string): string[] { diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 435c98902e..83d5fe31dc 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -1724,11 +1724,13 @@ import { handleValidateContentDelivery, } from './content-standards-handlers.js'; import { + ACCOUNT_REF_SCHEMA, ACCOUNT_TOOLS, SUPPORTED_BILLINGS, handleListAccounts, sandboxAccountRefForId, resolveAccountIdForRef, + resolveAccountCurrencyForRef, resolveGovernanceAgentsForAccount, handleSyncAccounts, handleSyncGovernance, @@ -2381,7 +2383,7 @@ async function deriveProductDiscoveryAccountScope( scopes.add(compactBrandScope({ domain: internal.__brand_domain, ...(typeof internal.__brand_id === 'string' && { brand_id: internal.__brand_id }), - ...(typeof internal.__brand_market === 'string' && { market: internal.__brand_market }), + ...(Array.isArray(internal.__brand_countries) && { countries: internal.__brand_countries }), })!); } } @@ -3959,21 +3961,6 @@ function vendorMetricBriefScore(product: Product, briefLower: string): number { // ── Shared schema fragments ────────────────────────────────────── -const ACCOUNT_REF_SCHEMA = { - type: 'object', - oneOf: [ - { properties: { account_id: { type: 'string' } }, required: ['account_id'] }, - { - properties: { - brand: { type: 'object', properties: { domain: { type: 'string' } }, required: ['domain'] }, - operator: { type: 'string' }, - sandbox: { type: 'boolean' }, - }, - required: ['brand', 'operator'], - }, - ], -} as const; - const FORMAT_ID_INPUT_SCHEMA = { type: 'object', properties: { @@ -4210,8 +4197,10 @@ function isProductDiscoveryTool(toolName: string): boolean { function compactBrandScope(brand: unknown): string | undefined { if (!isRecord(brand) || typeof brand.domain !== 'string' || brand.domain.length === 0) return undefined; const brandId = typeof brand.brand_id === 'string' ? brand.brand_id : ''; - const market = typeof brand.market === 'string' ? brand.market : ''; - return `b:${brand.domain.toLowerCase()}#${brandId}${market ? `@${market}` : ''}`; + const countries = Array.isArray(brand.countries) + ? brand.countries.filter((country): country is string => typeof country === 'string').sort() + : []; + return `b:${brand.domain.toLowerCase()}#${brandId}${countries.length ? `@${countries.join(',')}` : ''}`; } function productDiscoverySourceSchemaName(toolName: string): string | undefined { @@ -4401,7 +4390,7 @@ function proposalTermsDigest(commercialTerms: Record): string { function buildCanonicalCommercialTerms( proposal: Proposal, products: Map, - brand: { domain: string; brand_id?: string; market?: string }, + brand: { domain: string; brand_id?: string; countries?: string[] }, ): Record { const internal = proposal as unknown as Record; if (isRecord(internal.__canonical_commercial_terms)) { @@ -4452,7 +4441,7 @@ function buildCanonicalCommercialTerms( function withCanonicalProposalEnvelope( proposal: Proposal, products: Map, - brand: { domain: string; brand_id?: string; market?: string }, + brand: { domain: string; brand_id?: string; countries?: string[] }, options: { rebuild?: boolean } = {}, ): Proposal { const internal = { ...proposal } as unknown as Record; @@ -4480,7 +4469,7 @@ function outwardProposal(proposal: Record, products: Map typeof country === 'string').sort() }), }, ...(typeof requestAccount?.operator === 'string' && { operator: requestAccount.operator.toLowerCase() }), - ...(typeof requestAccount?.operator_region === 'string' - && { operator_region: requestAccount.operator_region }), + ...(isRecord(requestAccount?.operator_unit) + && typeof requestAccount.operator_unit.id === 'string' + && { operator_unit_id: requestAccount.operator_unit.id }), + ...(typeof requestAccount?.currency === 'string' && { currency: requestAccount.currency }), ...(typeof requestAccount?.sandbox === 'boolean' && { sandbox: requestAccount.sandbox }), }); proposals = proposals.map((proposal, index) => { @@ -6330,7 +6322,8 @@ async function handleGetProductsUnlocked( proposal_id: proposalId, ...(typeof requestBrand?.domain === 'string' && { __brand_domain: requestBrand.domain.toLowerCase() }), ...(typeof requestBrand?.brand_id === 'string' && { __brand_id: requestBrand.brand_id }), - ...(typeof requestBrand?.market === 'string' && { __brand_market: requestBrand.market }), + ...(Array.isArray(requestBrand?.countries) + && { __brand_countries: [...requestBrand.countries].filter(country => typeof country === 'string').sort() }), ...(typeof requestAccount?.account_id === 'string' && { __account_id: requestAccount.account_id }), ...(proposalAccountScope && { __account_scope: proposalAccountScope }), ...(typeof requestOpportunity?.opportunity_id === 'string' @@ -6345,7 +6338,8 @@ async function handleGetProductsUnlocked( ? requestBrand.domain.toLowerCase() : 'advertiser.example', ...(typeof requestBrand?.brand_id === 'string' && { brand_id: requestBrand.brand_id }), - ...(typeof requestBrand?.market === 'string' && { market: requestBrand.market }), + ...(Array.isArray(requestBrand?.countries) + && { countries: [...requestBrand.countries].filter(country => typeof country === 'string').sort() }), }, ); }); @@ -6416,7 +6410,7 @@ async function handleGetProductsUnlocked( if (existingSuccessor) return [existingSuccessor]; const brandDomain = (proposal as unknown as Record).__brand_domain; const brandId = (proposal as unknown as Record).__brand_id; - const brandMarket = (proposal as unknown as Record).__brand_market; + const brandCountries = (proposal as unknown as Record).__brand_countries; let successor = refinement?.action === 'finalize' ? executableProposalSnapshot( revision, @@ -6428,7 +6422,7 @@ async function handleGetProductsUnlocked( { domain: typeof brandDomain === 'string' ? brandDomain : 'advertiser.example', ...(typeof brandId === 'string' && { brand_id: brandId }), - ...(typeof brandMarket === 'string' && { market: brandMarket }), + ...(Array.isArray(brandCountries) && { countries: brandCountries }), }, { rebuild: true }, ); @@ -7525,10 +7519,31 @@ export async function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) async function handleCreateMediaBuyUnlocked(args: ToolArgs, ctx: TrainingContext) { const req = args as unknown as CreateMediaBuyRequest & ToolArgs & { paused?: boolean }; + const sessionKey = sessionKeyFromArgs(req, ctx.mode, ctx.userId, ctx.moduleId); const session = await getSession( - sessionKeyFromArgs(req, ctx.mode, ctx.userId, ctx.moduleId), + sessionKey, controllerFixtureSessionKey(req, ctx), ); + const accountCurrency = resolveAccountCurrencyForRef( + sessionKey, + ctx.principal, + ctx.resolvedAccount ?? req.account, + ); + if ( + accountCurrency + && req.total_budget?.currency + && req.total_budget.currency !== accountCurrency + ) { + return { + errors: [{ + code: 'INVALID_REQUEST', + message: `total_budget.currency must match the account currency (${accountCurrency}).`, + field: 'total_budget.currency', + recovery: 'correctable', + }] as TaskError[], + }; + } + const mediaBuyCurrency = accountCurrency ?? req.total_budget?.currency ?? 'USD'; let executedCompactProposal: Proposal | undefined; // Consume any single-shot directive registered by @@ -7598,7 +7613,7 @@ async function handleCreateMediaBuyUnlocked(args: ToolArgs, ctx: TrainingContext `${getCanonicalBase()}/sales`, governedRequestPayload(ctx, req as unknown as Record), buyBudget ?? 0, - req.total_budget?.currency ?? 'USD', + mediaBuyCurrency, ); if (commitmentError) return { errors: [commitmentError] }; } else if (session.governancePlans.size > 0 || governanceAgents.length > 0) { @@ -7905,11 +7920,11 @@ async function handleCreateMediaBuyUnlocked(args: ToolArgs, ctx: TrainingContext if (proposal) { const internal = proposal as unknown as Record; const accountRef = req.account as unknown as { account_id?: string }; - const requestBrand = req.brand as unknown as { domain?: string; brand_id?: string; market?: string }; + const requestBrand = req.brand as unknown as { domain?: string; brand_id?: string; countries?: string[] }; const boundAccountId = internal.__account_id; const boundBrandDomain = internal.__brand_domain; const boundBrandId = internal.__brand_id; - const boundBrandMarket = internal.__brand_market; + const boundBrandCountries = internal.__brand_countries; const hasCompactOwnerBinding = typeof boundAccountId === 'string' || typeof boundBrandDomain === 'string' || typeof boundBrandId === 'string'; @@ -7920,8 +7935,8 @@ async function handleCreateMediaBuyUnlocked(args: ToolArgs, ctx: TrainingContext requestBrand?.domain?.toLowerCase() === boundBrandDomain && (typeof requestBrand.brand_id === 'string' ? requestBrand.brand_id : undefined) === (typeof boundBrandId === 'string' ? boundBrandId : undefined) - && (typeof requestBrand.market === 'string' ? requestBrand.market : undefined) - === (typeof boundBrandMarket === 'string' ? boundBrandMarket : undefined) + && JSON.stringify([...(requestBrand.countries ?? [])].sort()) + === JSON.stringify(Array.isArray(boundBrandCountries) ? [...boundBrandCountries].sort() : []) ); if (hasCompactOwnerBinding && (!accountMatches || !brandMatches)) proposal = undefined; } @@ -7942,7 +7957,7 @@ async function handleCreateMediaBuyUnlocked(args: ToolArgs, ctx: TrainingContext const internalProposal = proposal as unknown as Record; const compactProposal = typeof internalProposal.__brand_domain === 'string' || typeof internalProposal.__brand_id === 'string' - || typeof internalProposal.__brand_market === 'string' + || Array.isArray(internalProposal.__brand_countries) || typeof internalProposal.__account_id === 'string'; if (internalProposal.__declined === true) { return { @@ -8168,6 +8183,15 @@ async function handleCreateMediaBuyUnlocked(args: ToolArgs, ctx: TrainingContext const pricingView = pricing as unknown as PricingOptionView; const pricingStructure = pricingStructureForOption(pricing); + if (pricingView.currency !== mediaBuyCurrency) { + errors.push({ + code: 'INVALID_REQUEST', + message: `${pkgLabel}: pricing option ${pkg.pricing_option_id} is denominated in ${pricingView.currency}, but the media buy uses ${mediaBuyCurrency}.`, + field: `packages[${i}].pricing_option_id`, + recovery: 'correctable', + } as TaskError); + continue; + } const floorPrice = pricingStructure === 'auction' ? pricingView.floor_price : undefined; const isAuction = pricingStructure === 'auction'; const seededPricingKey = `${pkg.product_id}:${pkg.pricing_option_id}`; @@ -8343,7 +8367,7 @@ async function handleCreateMediaBuyUnlocked(args: ToolArgs, ctx: TrainingContext accountRef: persistedAccountRef, brandRef: req.brand, status: req.paused === true ? 'paused' : 'active', - currency: req.total_budget?.currency ?? 'USD', + currency: mediaBuyCurrency, totalBudget: req.total_budget?.amount ?? createdPackages.reduce((sum, pkg) => sum + (pkg.budget || 0), 0), ...((req as unknown as { budget_allocation?: Record }).budget_allocation diff --git a/server/src/training-agent/types.ts b/server/src/training-agent/types.ts index f87b371b32..941c94f5c2 100644 --- a/server/src/training-agent/types.ts +++ b/server/src/training-agent/types.ts @@ -416,15 +416,21 @@ export interface ToolArgs { account?: AccountRef; brand?: BrandRef } export interface AccountRef { account_id?: string; - brand?: { domain: string; brand_id?: string; market?: string }; + brand?: { domain: string; brand_id?: string; countries?: string[] }; operator?: string; - operator_region?: string; + operator_unit?: OperatorUnit; + currency?: string; sandbox?: boolean; } +export interface OperatorUnit { + id: string; + name?: string; +} + export interface BrandRef { domain: string; - market?: string; + countries?: string[]; name?: string; } diff --git a/server/tests/unit/account-handlers.test.ts b/server/tests/unit/account-handlers.test.ts index c65327de6e..9c117ad14a 100644 --- a/server/tests/unit/account-handlers.test.ts +++ b/server/tests/unit/account-handlers.test.ts @@ -125,20 +125,22 @@ describe('sync_accounts', () => { expect(acct.setup).toBeUndefined(); }); - it('keeps brand markets and operator regions distinct in the natural key', async () => { + it('keeps brand countries, operator units, and fixed currency in the natural key', async () => { const { result } = await simulateCallTool(server, 'sync_accounts', { accounts: [ { - brand: { domain: 'nova-athletics.example', market: 'NL' }, + brand: { domain: 'nova-athletics.example', countries: ['NL'] }, operator: 'nova-athletics.example', - operator_region: 'emea', + operator_unit: { id: '234284238', name: 'Nova EMEA' }, + currency: 'EUR', billing: 'operator', sandbox: true, }, { - brand: { domain: 'nova-athletics.example', market: 'BE' }, + brand: { domain: 'nova-athletics.example', countries: ['BE'] }, operator: 'nova-athletics.example', - operator_region: 'emea', + operator_unit: { id: '234284238', name: 'Nova EMEA' }, + currency: 'EUR', billing: 'operator', sandbox: true, }, @@ -149,10 +151,23 @@ describe('sync_accounts', () => { expect(accounts).toHaveLength(2); expect(new Set(accounts.map(account => account.account_id)).size).toBe(2); expect(accounts[0]).toMatchObject({ - brand: { domain: 'nova-athletics.example', market: 'NL' }, + brand: { domain: 'nova-athletics.example', countries: ['NL'] }, operator: 'nova-athletics.example', - operator_region: 'emea', + operator_unit: { id: '234284238', name: 'Nova EMEA' }, + currency: 'EUR', }); + + const { result: listed } = await simulateCallTool(server, 'list_accounts', {}); + expect((listed.accounts as Record[])).toEqual(expect.arrayContaining([ + expect.objectContaining({ + account_id: accounts[0].account_id, + brand: { domain: 'nova-athletics.example', countries: ['NL'] }, + operator: 'nova-athletics.example', + operator_unit: { id: '234284238', name: 'Nova EMEA' }, + currency: 'EUR', + sandbox: true, + }), + ])); }); it('non-sandbox account is pending_approval with setup URL', async () => { @@ -718,7 +733,7 @@ describe('sync_accounts', () => { const { result: created } = await simulateCallTool(server, 'sync_accounts', { accounts: [ { - brand: { domain: 'acme.com', brand_id: 'acme-main' }, + brand: { domain: 'acme.com', brand_id: 'acme_main' }, operator: 'agency-one', billing: 'operator', sandbox: true, @@ -742,7 +757,7 @@ describe('sync_accounts', () => { const { result: byNaturalKey } = await simulateCallTool(server, 'list_accounts', { account: { - brand: { domain: 'acme.com', brand_id: 'acme-main' }, + brand: { domain: 'acme.com', brand_id: 'acme_main' }, operator: 'agency-one', sandbox: true, }, diff --git a/server/tests/unit/product-discovery-schema-parity.test.ts b/server/tests/unit/product-discovery-schema-parity.test.ts index df9b050c12..7b02b50696 100644 --- a/server/tests/unit/product-discovery-schema-parity.test.ts +++ b/server/tests/unit/product-discovery-schema-parity.test.ts @@ -102,6 +102,11 @@ describe('product discovery MCP schema parity', () => { it('keeps the compact lifecycle within its tools/list context budget', () => { const tools = productDiscoveryAliasToolDefinitions(); + for (const tool of tools) { + expect(tool.inputSchema).not.toHaveProperty('$id'); + expect(tool.inputSchema).not.toHaveProperty('title'); + expect(tool.inputSchema).not.toHaveProperty('description'); + } const totalBytes = tools.reduce( (sum, tool) => sum + Buffer.byteLength(JSON.stringify(tool.inputSchema)), 0, diff --git a/server/tests/unit/training-agent-account-scope.test.ts b/server/tests/unit/training-agent-account-scope.test.ts index 74f54b3740..efd17eafab 100644 --- a/server/tests/unit/training-agent-account-scope.test.ts +++ b/server/tests/unit/training-agent-account-scope.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; +import { createHash } from 'node:crypto'; import { AccountRefValidationError, accountScopeFromRef, @@ -29,7 +30,7 @@ describe('canonical AccountRef scope', () => { expect(accountScopeFromRef({ brand: { domain: 'House.Example', brand_id: 'spark' }, operator: 'Pinnacle.Example', - })).toBe('n:house.example:spark:pinnacle.example:0'); + })).toBe('n:{"brand":{"domain":"house.example","brand_id":"spark"},"operator":"pinnacle.example","operator_unit_id":null,"currency":null,"sandbox":false}'); }); it('partitions every natural-key discriminator', () => { @@ -39,27 +40,50 @@ describe('canonical AccountRef scope', () => { accountScopeFromRef({ ...base, operator: 'two.example' }), accountScopeFromRef({ ...base, sandbox: true }), accountScopeFromRef({ ...base, brand: { ...base.brand, brand_id: 'spark' } }), - accountScopeFromRef({ ...base, brand: { ...base.brand, market: 'NL' } }), - accountScopeFromRef({ ...base, operator_region: 'emea' }), + accountScopeFromRef({ ...base, brand: { ...base.brand, countries: ['NL'] } }), + accountScopeFromRef({ ...base, operator_unit: { id: '234284238', name: 'Nova EMEA' } }), + accountScopeFromRef({ ...base, currency: 'EUR' }), ]); - expect(scopes.size).toBe(6); + expect(scopes.size).toBe(7); }); - it('represents a country-market brand under a regional operator', () => { + it('represents a country-scoped brand under an operator unit and fixed currency', () => { const account = { - brand: { domain: 'Nova-Athletics.Example', market: 'NL' }, + brand: { domain: 'Nova-Athletics.Example', countries: ['NL', 'BE'] }, operator: 'Nova-Athletics.Example', - operator_region: 'emea', + operator_unit: { id: '234284238', name: 'Nova EMEA' }, + currency: 'EUR', }; expect(canonicalizeAccountRef(account)).toEqual({ kind: 'natural', - brand: { domain: 'nova-athletics.example', market: 'NL' }, + brand: { domain: 'nova-athletics.example', countries: ['BE', 'NL'] }, operator: 'nova-athletics.example', - operator_region: 'emea', + operator_unit: { id: '234284238', name: 'Nova EMEA' }, + currency: 'EUR', sandbox: false, }); expect(accountScopeFromRef(account)) - .toBe('n:nova-athletics.example:-:nova-athletics.example:0:m:NL:r:emea'); + .toBe('n:{"brand":{"domain":"nova-athletics.example","countries":["BE","NL"]},"operator":"nova-athletics.example","operator_unit_id":"234284238","currency":"EUR","sandbox":false}'); + expect(accountScopeFromRef({ + ...account, + brand: { ...account.brand, countries: ['BE', 'NL'] }, + operator_unit: { ...account.operator_unit, name: 'Renamed EMEA seat' }, + })).toBe(accountScopeFromRef(account)); + }); + + it('excludes permitted BrandRef inline overrides from account identity', () => { + const base = { + brand: { domain: 'house.example', countries: ['NL'] }, + operator: 'pinnacle.example', + }; + expect(accountScopeFromRef({ + ...base, + brand: { + ...base.brand, + industries: ['retail'], + data_subject_contestation: { url: 'https://house.example/privacy' }, + }, + })).toBe(accountScopeFromRef(base)); }); it.each([ @@ -68,8 +92,10 @@ describe('canonical AccountRef scope', () => { [{ account_id: 'acct_123', sandbox: false }, 'exactly one identity'], [{ brand: { domain: 'house.example' } }, 'exactly one identity'], [{ brand: { domain: 'house.example' }, operator: 'one.example', unexpected: true }, "field 'unexpected'"], - [{ brand: { domain: 'house.example', market: 'nl' }, operator: 'one.example' }, 'ISO 3166-1'], - [{ brand: { domain: 'house.example' }, operator: 'one.example', operator_region: 'EMEA' }, 'lowercase'], + [{ brand: { domain: 'house.example', countries: ['nl'] }, operator: 'one.example' }, 'ISO 3166-1'], + [{ brand: { domain: 'house.example', countries: ['NL', 'NL'] }, operator: 'one.example' }, 'unique'], + [{ brand: { domain: 'house.example' }, operator: 'one.example', operator_unit: { id: '' } }, 'stable operator-defined'], + [{ brand: { domain: 'house.example' }, operator: 'one.example', currency: 'eur' }, 'ISO 4217'], [{ account_id: 'acct_123', unexpected: true }, "field 'unexpected'"], ])('rejects invalid closed-union shape %#', (value, message) => { expect(() => canonicalizeAccountRef(value)).toThrow(AccountRefValidationError); @@ -79,13 +105,18 @@ describe('canonical AccountRef scope', () => { describe('canonical session scope', () => { it('uses the complete account identity without requiring a principal', () => { + const naturalScope = accountScopeFromRef({ + brand: { domain: 'House.Example', brand_id: 'spark' }, + operator: 'Pinnacle.Example', + sandbox: true, + }); expect(getProductsSessionKeyFromArgs({ account: { brand: { domain: 'House.Example', brand_id: 'spark' }, operator: 'Pinnacle.Example', sandbox: true, }, - }, 'open')).toBe('open:n:house.example:spark:pinnacle.example:1'); + }, 'open')).toBe(`open:h:${createHash('sha256').update(naturalScope).digest('hex')}`); expect(getProductsSessionKeyFromArgs({ account: { account_id: 'acct_123' } }, 'open')) .toBe('open:a:acct_123'); expect(getProductsSessionKeyFromArgs({ @@ -117,7 +148,7 @@ describe('canonical session scope', () => { }, 'open', undefined, undefined, 'workos:org_one'); expect(first).toBe(same); - expect(first).toMatch(/^open:p:[a-f0-9]{64}:n:house\.example:-:one\.example:0$/); + expect(first).toMatch(/^open:p:[a-f0-9]{64}:h:[a-f0-9]{64}$/); expect(new Set([first, otherPrincipal, otherOperator]).size).toBe(3); }); @@ -153,9 +184,9 @@ describe('custom-tool account scoping', () => { sandbox: true, }; expect(deriveAccountScope({ account })) - .toBe('n:house.example:spark:pinnacle.example:1'); + .toBe('n:{"brand":{"domain":"house.example","brand_id":"spark"},"operator":"pinnacle.example","operator_unit_id":null,"currency":null,"sandbox":true}'); expect(deriveAccountScope({ usage: [{ account }] })) - .toBe('n:house.example:spark:pinnacle.example:1'); + .toBe('n:{"brand":{"domain":"house.example","brand_id":"spark"},"operator":"pinnacle.example","operator_unit_id":null,"currency":null,"sandbox":true}'); }); it('does not silently fall through an explicit invalid top-level account', () => { diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index 6cea726237..e5f56ab8db 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -3233,6 +3233,39 @@ describe('create_media_buy handler', () => { expect(result.errors).toBeUndefined(); }); + it('enforces an advertiser account\'s immutable currency', async () => { + const { productId, pricingOptionId } = getFirstProductAndPricing(); + const server = createTrainingAgentServer(DEFAULT_CTX); + const account = { + brand: { domain: 'currency-bound.example' }, + operator: 'currency-bound.example', + currency: 'EUR', + sandbox: true, + }; + await simulateCallTool(server, 'sync_accounts', { + accounts: [{ ...account, billing: 'operator' }], + }); + + const { result, isError } = await simulateCallTool(server, 'create_media_buy', { + account, + brand: account.brand, + total_budget: { amount: 50000, currency: 'USD' }, + start_time: '2027-06-01T00:00:00Z', + end_time: '2027-07-01T00:00:00Z', + packages: [{ + product_id: productId, + pricing_option_id: pricingOptionId, + budget: 50000, + }], + }); + + expect(isError).toBe(true); + expect(result).toMatchObject({ + code: 'INVALID_REQUEST', + field: 'total_budget.currency', + }); + }); + it('accepts every advertised legacy selector whose canonical kind is intentionally non-equivalent', async () => { const server = createTrainingAgentServer({ ...DEFAULT_CTX, storyboardCompat: { version: '3.0' } }); const cases = buildCatalog().flatMap(({ product }) => { diff --git a/static/schemas/source/account/list-accounts-request.json b/static/schemas/source/account/list-accounts-request.json index 3f754a6244..0290327378 100644 --- a/static/schemas/source/account/list-accounts-request.json +++ b/static/schemas/source/account/list-accounts-request.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/account/list-accounts-request.json", "title": "List Accounts Request", - "description": "Request parameters for listing accounts accessible to the authenticated agent. For upstream-managed account namespaces, this is the mandatory discovery surface for seller-assigned account_id values before account-scoped calls when a credential may access more than one account, and the preferred singleton discovery surface when a credential is bound to exactly one account.", + "description": "Request parameters for listing accounts accessible to the authenticated agent. For upstream-managed namespaces, this discovers seller/storefront account_id values. For buyer-declared accounts, responses return the natural-key fields needed to reconstruct an AccountRef after a cold start.", "type": "object", "allOf": [ { @@ -12,7 +12,7 @@ "properties": { "account": { "$ref": "/schemas/core/account-ref.json", - "description": "Optional exact account filter. Use `account_id` to retrieve one known account from an account_id namespace, or the natural key (`brand` + `operator`, optionally `sandbox`) for buyer-declared account sellers. When present, the seller returns only matching accounts visible to the authenticated caller." + "description": "Optional exact account filter. Use `account_id` to retrieve one known seller/storefront account, or the complete natural key (`brand` + `operator` + optional `operator_unit`, fixed `currency`, and `sandbox`) for buyer-declared accounts. When present, the seller returns only matching accounts visible to the authenticated caller." }, "status": { "type": "string", diff --git a/static/schemas/source/account/list-accounts-response.json b/static/schemas/source/account/list-accounts-response.json index 26218640ac..202df524fb 100644 --- a/static/schemas/source/account/list-accounts-response.json +++ b/static/schemas/source/account/list-accounts-response.json @@ -15,7 +15,7 @@ "properties": { "accounts": { "type": "array", - "description": "Array of accounts accessible to the authenticated agent. Each entry is the full Account object plus an optional `authorization` object describing what the calling agent is permitted to do on that account.", + "description": "Array of accounts accessible to the authenticated agent. Each entry is the full Account object plus optional authorization. Buyer-declared entries include brand, operator, and any operator_unit, fixed currency, and sandbox qualifiers so the natural AccountRef round-trips after a cold start.", "items": { "$ref": "/schemas/core/account-with-authorization.json" } @@ -52,10 +52,16 @@ "name": "Acme c/o Pinnacle", "advertiser": "Acme Corp", "billing_proxy": "Pinnacle Media", - "brand": { - "domain": "acme-corp.com" - }, - "operator": "pinnacle-media.com", + "brand": { + "domain": "acme-corp.com", + "countries": ["CA", "US"] + }, + "operator": "pinnacle-media.com", + "operator_unit": { + "id": "seat_north_america_01", + "name": "North America" + }, + "currency": "USD", "billing": "operator", "account_scope": "operator_brand", "status": "active" diff --git a/static/schemas/source/account/sync-accounts-request.json b/static/schemas/source/account/sync-accounts-request.json index 09eb834534..54d38de330 100644 --- a/static/schemas/source/account/sync-accounts-request.json +++ b/static/schemas/source/account/sync-accounts-request.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/account/sync-accounts-request.json", "title": "Sync Accounts Request", - "description": "Sync advertiser account state with a seller. Two modes, distinguished by the key on each per-account entry:\n\n- **Provisioning mode** (`brand` + `operator` + `billing` at the entry root): the agent declares which brands it represents, who operates on each brand's behalf, and the billing model. Optional `brand.market` and `operator_region` qualify those identities. The seller provisions or links accounts via upsert. Used when brand + operator + optional operator_region (+ sandbox) is the durable protocol key for buyer-declared accounts (`require_operator_auth: false`). Sellers MAY echo a seller-assigned account_id, but they MUST continue accepting the complete natural-key AccountRef for every account provisioned this way.\n\n- **Settings-update mode** (`account` field carrying an [`AccountRef`](/schemas/core/account-ref.json)): targets an existing account by `account_id` (or by natural key for buyer-declared accounts). The seller updates the account's settable state (notification subscriptions, payment terms, billing entity refinements) — no provisioning side effects. Used for account_id namespaces only when the seller exposes this task for account settings; the account_id itself is discovered via `list_accounts` for upstream-managed namespaces or supplied out-of-band for seller-defined namespaces. Buyer-declared account sellers MAY also accept this mode for settings updates against accounts they previously provisioned.\n\nExactly one of the two key shapes is allowed per entry. Sellers that do not implement settings-update mode MUST return `UNSUPPORTED_PROVISIONING` on entries keyed by `account.account_id`; sellers that do not provision through sync_accounts MUST return `UNSUPPORTED_PROVISIONING` on entries keyed by the natural-key fields. Account-id namespace provisioning is out of scope unless a future explicit capability declares it.", + "description": "Sync advertiser account state with a seller. Two modes, distinguished by the key on each per-account entry:\n\n- **Provisioning mode** (`brand` + `operator` + `billing` at the entry root): the agent declares the advertiser identity, operator, optional operator-owned buying unit, optional fixed account currency, sandbox disposition, and billing model. The seller provisions or links the corresponding advertiser object via upsert. `brand.countries`, `operator_unit.id`, `currency`, and `sandbox` participate in the buyer-declared natural key when present; `operator_unit.name` is display metadata only. The seller MAY echo a seller-assigned account_id but MUST continue accepting the complete natural-key AccountRef.\n\n- **Settings-update mode** (`account` field carrying an [`AccountRef`](/schemas/core/account-ref.json)): targets an existing account by seller/storefront `account_id` or buyer-declared natural key. The seller updates settable state without provisioning side effects.\n\nExactly one key shape is allowed per entry. Sellers that do not implement one mode return `UNSUPPORTED_PROVISIONING` for that mode.", "type": "object", "allOf": [ { @@ -13,7 +13,7 @@ "properties": { "idempotency_key": { "type": "string", - "description": "Client-generated unique key for at-most-once execution. Natural per-account upsert keys (brand, operator, optional operator_region, sandbox) handle resource-level dedup, but the envelope triggers onboarding webhooks, billing setup, and audit events — this key prevents those side effects from firing twice on retry. MUST be unique per (seller, request) pair. Use a fresh UUID v4 for each request.", + "description": "Client-generated unique key for at-most-once execution. Natural per-account upsert keys handle resource-level dedup, but the envelope triggers onboarding webhooks, billing setup, and audit events — this key prevents those side effects from firing twice on retry. MUST be unique per (seller, request) pair. Use a fresh UUID v4 for each request.", "minLength": 16, "maxLength": 255, "pattern": "^[A-Za-z0-9_.:-]{16,255}$" @@ -38,12 +38,14 @@ "description": "Domain of the entity operating on the brand's behalf (e.g., 'pinnacle-media.com'). When the brand operates directly, this is the brand's domain. Verified against the brand's authorized_operators in brand.json. Required for **provisioning mode**; MUST be absent in settings-update mode.", "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" }, - "operator_region": { + "operator_unit": { + "$ref": "/schemas/core/operator-unit.json", + "description": "Optional operator-owned business unit, agency seat, or platform account for provisioning mode. operator_unit.id participates in the natural key; name is mapping/display metadata. MUST be absent in settings-update mode." + }, + "currency": { "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[a-z0-9][a-z0-9_-]*$", - "description": "Optional operator-defined business region for provisioning mode. It becomes part of the natural account key. MUST be absent in settings-update mode." + "pattern": "^[A-Z]{3}$", + "description": "Optional immutable ISO 4217 transaction currency for a currency-bound advertiser object. When supplied, it participates in the natural key and all media buys on the account use it. Omit for per-media-buy currency selection. MUST be absent in settings-update mode." }, "billing": { "$ref": "/schemas/enums/billing-party.json", @@ -133,7 +135,14 @@ { "not": { "required": [ - "operator_region" + "operator_unit" + ] + } + }, + { + "not": { + "required": [ + "currency" ] } }, @@ -182,15 +191,21 @@ "description": "Agency syncing multiple advertisers with different billing", "data": { "idempotency_key": "a7f9c2e4-1234-4567-89ab-cdef01234567", - "accounts": [ - { - "brand": { - "domain": "nova-brands.com", - "brand_id": "spark" + "accounts": [ + { + "brand": { + "domain": "nova-brands.com", + "brand_id": "spark", + "countries": ["DE", "NL"] + }, + "operator": "pinnacle-media.com", + "operator_unit": { + "id": "seat_emea_01", + "name": "EMEA" + }, + "currency": "EUR", + "billing": "operator" }, - "operator": "pinnacle-media.com", - "billing": "operator" - }, { "brand": { "domain": "nova-brands.com", diff --git a/static/schemas/source/account/sync-accounts-response.json b/static/schemas/source/account/sync-accounts-response.json index 9a12554353..6cbe71c94c 100644 --- a/static/schemas/source/account/sync-accounts-response.json +++ b/static/schemas/source/account/sync-accounts-response.json @@ -41,6 +41,15 @@ "type": "string", "description": "Operator domain, echoed from request" }, + "operator_unit": { + "$ref": "/schemas/core/operator-unit.json", + "description": "Operator-owned business unit, agency seat, or platform account, echoed when supplied. The stable id participates in the natural key; name is mutable display metadata. This is distinct from the seller/storefront account_id." + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$", + "description": "Immutable account currency, echoed when the advertiser object is currency-bound." + }, "name": { "type": "string", "description": "Human-readable account name assigned by the seller" @@ -234,10 +243,16 @@ "account_id": "acc_spark_001", "brand": { "domain": "nova-brands.com", - "brand_id": "spark" + "brand_id": "spark", + "countries": ["DE", "NL"] }, "operator": "pinnacle-media.com", - "name": "Spark (via Pinnacle)", + "operator_unit": { + "id": "seat_emea_01", + "name": "EMEA" + }, + "currency": "EUR", + "name": "Spark (via EMEA)", "action": "created", "status": "active", "billing": "operator", diff --git a/static/schemas/source/core/account-ref.json b/static/schemas/source/core/account-ref.json index 4b1c56584f..937a383382 100644 --- a/static/schemas/source/core/account-ref.json +++ b/static/schemas/source/core/account-ref.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/account-ref.json", "title": "Account Reference", - "description": "Reference to an account by seller-assigned ID or natural key. Use account_id when the seller or upstream platform owns the canonical account namespace: either a seller-defined account supplied out-of-band, or an upstream-managed namespace discovered with list_accounts before account-scoped calls. If a credential may access more than one account, the seller MUST expose list_accounts; if a credential is bound to exactly one account, the seller SHOULD expose list_accounts returning that singleton and MAY omit it only when the same explicit account_id is provided through another declared path or out-of-band onboarding. Use the natural key (brand + operator + optional operator_region + sandbox) for buyer-declared accounts (require_operator_auth: false), whether provisioned explicitly through sync_accounts or lazily on the first account-scoped request under the unambiguous-default rules. brand.market independently qualifies the advertiser's country market. For sandbox: account_id namespaces use pre-existing test accounts discovered via list_accounts or supplied out-of-band; buyer-declared accounts use the natural key with sandbox: true.", + "description": "Reference to an advertiser account by seller-assigned ID or natural key. account_id is the seller/storefront handle returned by list_accounts. The natural key is brand + operator + optional operator_unit.id + optional fixed currency + sandbox; it describes the advertiser object the seller resolves or provisions. brand.countries qualifies the advertiser's geographic identity. operator_unit identifies the operator-owned buying seat or business unit and is distinct from the seller account. operator_unit.name is display metadata and does not participate in identity. Use account_id when the seller or upstream platform owns the canonical account namespace; use the natural key for buyer-declared accounts (require_operator_auth: false).", "type": "object", "oneOf": [ { @@ -28,12 +28,14 @@ "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$", "x-entity": "operator" }, - "operator_region": { + "operator_unit": { + "$ref": "/schemas/core/operator-unit.json", + "description": "Optional operator-owned business unit, agency seat, or platform account. Only id participates in the natural account key; name is mutable display metadata." + }, + "currency": { "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[a-z0-9][a-z0-9_-]*$", - "description": "Stable operator-defined business-region identifier. Omit when the operator does not split accounts regionally." + "pattern": "^[A-Z]{3}$", + "description": "Immutable ISO 4217 transaction currency when the seller's advertiser object is currency-bound. When present, this is part of the natural account key and media buys on the account MUST use it. Omit when currency is selected independently per media buy." }, "sandbox": { "type": "boolean", @@ -49,7 +51,7 @@ { "account_id": "acc_acme_001" }, { "brand": { "domain": "acme-corp.com" }, "operator": "acme-corp.com" }, { "brand": { "domain": "nova-brands.com", "brand_id": "spark" }, "operator": "pinnacle-media.com" }, - { "brand": { "domain": "nova-athletics.example", "market": "NL" }, "operator": "nova-athletics.example", "operator_region": "emea" }, + { "brand": { "domain": "nova-athletics.example", "countries": ["NL"] }, "operator": "nova-athletics.example", "operator_unit": { "id": "234284238", "name": "Nova EMEA" }, "currency": "EUR" }, { "brand": { "domain": "acme-corp.com" }, "operator": "acme-corp.com", "sandbox": true } ] } diff --git a/static/schemas/source/core/account.json b/static/schemas/source/core/account.json index b262d556d7..62f96ca471 100644 --- a/static/schemas/source/core/account.json +++ b/static/schemas/source/core/account.json @@ -36,12 +36,14 @@ "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$", "x-entity": "operator" }, - "operator_region": { + "operator_unit": { + "$ref": "/schemas/core/operator-unit.json", + "description": "Operator-owned business unit, agency seat, or platform account associated with this advertiser account. The id round-trips from the natural key; name is mutable display metadata. This is distinct from account_id, which belongs to the seller/storefront namespace." + }, + "currency": { "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[a-z0-9][a-z0-9_-]*$", - "description": "Operator-defined business region that qualifies this account's operator identity. Omit when the operator does not split accounts regionally." + "pattern": "^[A-Z]{3}$", + "description": "Immutable transaction currency when the seller's advertiser object is currency-bound. Media buys on this account MUST use this currency. Omit when the account selects currency independently per media buy." }, "billing": { "$ref": "/schemas/enums/billing-party.json", diff --git a/static/schemas/source/core/brand-key.json b/static/schemas/source/core/brand-key.json index 970a6c5e7a..a57e3bc40f 100644 --- a/static/schemas/source/core/brand-key.json +++ b/static/schemas/source/core/brand-key.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/brand-key.json", "title": "Brand Key", - "description": "Stable brand identity used to resolve the canonical brand manifest, optionally scoped to one country market. The manifest URL is derived from domain; callers do not send brand assets or per-call brand overrides.", + "description": "Stable brand identity used to resolve the canonical brand manifest, optionally qualified by the countries of a commercial advertiser entity. The manifest URL is derived from domain; callers do not send brand assets or per-call brand overrides. Countries do not target delivery.", "type": "object", "properties": { "domain": { @@ -14,10 +14,15 @@ "$ref": "/schemas/core/brand-id.json", "description": "Brand within a house-of-brands manifest. Omit for a single-brand domain." }, - "market": { - "type": "string", - "pattern": "^[A-Z]{2}$", - "description": "ISO 3166-1 alpha-2 country market for this brand identity (for example, NL). Omit when the identity is global or the house does not split the brand by market. This qualifies account and proposal identity; it is not a delivery-targeting instruction." + "countries": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Z]{2}$" + }, + "minItems": 1, + "uniqueItems": true, + "description": "Canonical set of ISO 3166-1 alpha-2 countries for this advertiser identity. Omit when the identity is global or the house does not split the brand geographically. Array order is not meaningful; producers MUST sort codes lexicographically before computing keys or signatures. This qualifies account and proposal identity and is not delivery targeting." } }, "required": ["domain"], diff --git a/static/schemas/source/core/brand-ref.json b/static/schemas/source/core/brand-ref.json index a9598be5ed..190c0feb5b 100644 --- a/static/schemas/source/core/brand-ref.json +++ b/static/schemas/source/core/brand-ref.json @@ -14,10 +14,15 @@ "$ref": "/schemas/core/brand-id.json", "description": "Brand identifier within the house portfolio. Optional for single-brand domains." }, - "market": { - "type": "string", - "pattern": "^[A-Z]{2}$", - "description": "ISO 3166-1 alpha-2 country market for this brand identity. Omit for a global/default identity. This qualifies account identity and is not a delivery-targeting instruction." + "countries": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Z]{2}$" + }, + "minItems": 1, + "uniqueItems": true, + "description": "Canonical set of ISO 3166-1 alpha-2 countries for this advertiser identity. Omit for a global/default identity. Array order is not meaningful; producers MUST sort codes lexicographically before computing keys or signatures. This qualifies account identity and is not delivery targeting." }, "industries": { "type": "array", @@ -84,7 +89,7 @@ }, { "domain": "nova-athletics.example", - "market": "NL" + "countries": ["NL"] } ] } diff --git a/static/schemas/source/core/canonical-account-ref.json b/static/schemas/source/core/canonical-account-ref.json index f552293024..69e2ecd2d9 100644 --- a/static/schemas/source/core/canonical-account-ref.json +++ b/static/schemas/source/core/canonical-account-ref.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/canonical-account-ref.json", "title": "Canonical Account Reference", - "description": "Compact account identity for canonical 3.2 tools. Uses either a seller-assigned account_id or the buyer-declared natural key without BrandRef inline overrides. The natural key is (brand, operator, operator_region, sandbox): brand.market identifies the advertiser's country market, while operator_region identifies the operator's business region.", + "description": "Compact advertiser-account identity for canonical 3.2 tools. Uses either the seller/storefront account_id returned by list_accounts or the buyer-declared natural key without BrandRef inline overrides. The natural key is brand + operator + optional operator_unit.id + optional fixed currency + sandbox. brand.countries qualifies the commercial advertiser identity without targeting delivery; operator_unit identifies the operator-owned buying context. operator_unit.name is display metadata and is excluded from identity.", "type": "object", "anyOf": [ { @@ -26,12 +26,14 @@ "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$", "x-entity": "operator" }, - "operator_region": { + "operator_unit": { + "$ref": "/schemas/core/operator-unit.json", + "description": "Optional operator-owned business unit, agency seat, or platform account. Only id participates in identity; name is mutable display metadata." + }, + "currency": { "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[a-z0-9][a-z0-9_-]*$", - "description": "Stable operator-defined business-region identifier (for example, emea, apac, or benelux). Omit when the operator does not split accounts regionally. This is part of the natural account key and is not a delivery-targeting instruction." + "pattern": "^[A-Z]{3}$", + "description": "Immutable ISO 4217 transaction currency when the advertiser object is currency-bound. When present, this is part of the natural key and all media buys on the account use it. Omit for per-media-buy currency selection." }, "sandbox": { "type": "boolean", @@ -45,9 +47,10 @@ "examples": [ { "account_id": "acc_nova_nl" }, { - "brand": { "domain": "nova-athletics.example", "market": "NL" }, + "brand": { "domain": "nova-athletics.example", "countries": ["NL"] }, "operator": "nova-athletics.example", - "operator_region": "emea" + "operator_unit": { "id": "234284238", "name": "Nova EMEA" }, + "currency": "EUR" } ] } diff --git a/static/schemas/source/core/operator-unit.json b/static/schemas/source/core/operator-unit.json new file mode 100644 index 0000000000..9727442bcb --- /dev/null +++ b/static/schemas/source/core/operator-unit.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/operator-unit.json", + "title": "Operator Unit", + "description": "Operator-owned buying context used to distinguish business units, agency seats, or platform accounts beneath one operator domain. The id is stable identity in the operator's namespace; name is mutable display metadata and never participates in account identity.", + "x-entity": "operator_unit", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]*$", + "description": "Stable identifier assigned by the operator. Numeric platform IDs and durable slugs are both valid. Scoped by the enclosing operator domain." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Human-readable seat or business-unit name, such as Nova EMEA. This label may change and is not part of the natural account key." + } + }, + "required": ["id"], + "additionalProperties": false, + "examples": [ + { "id": "234284238", "name": "Nova EMEA" }, + { "id": "benelux-seat", "name": "Nova Benelux" } + ] +} diff --git a/static/schemas/source/core/x-entity-types.json b/static/schemas/source/core/x-entity-types.json index eaf386bb5b..8ad7914f55 100644 --- a/static/schemas/source/core/x-entity-types.json +++ b/static/schemas/source/core/x-entity-types.json @@ -10,6 +10,7 @@ "rights_grant", "account", "operator", + "operator_unit", "media_buy", "package", "product", @@ -55,6 +56,7 @@ "rights_grant": "A rights grant identifier covering any lifecycle state (acquired, pending_approval, rejected). `rights_id` across brand/* schemas. Named `grant` rather than `contract` because the identifier is issued at request time — a bilateral contract may or may not exist yet.", "account": "Billing/scope account in the seller's namespace. `account_id` or the natural-key `{brand, operator}` form; both resolve to the same entity.", "operator": "An operator (seller) identity, typically by domain. Distinct from `account` — one operator issues many accounts.", + "operator_unit": "An operator-owned buying context beneath one operator domain, such as a business unit, agency seat, or platform account. Identity is the tuple `(operator domain, operator_unit.id)`; `operator_unit.name` is mutable display metadata and never participates in identity.", "media_buy": "A media buy / campaign. `media_buy_id` across media-buy/* schemas.", "package": "A line item within a media buy. `package_id` across media-buy/* schemas.", "product": "A seller inventory product. `product_id` across media-buy/* and core/product.json.", diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index 36e43ad534..a05db7c14e 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -588,7 +588,7 @@ "properties": { "require_operator_auth": { "type": "boolean", - "description": "Whether the seller requires operator-level credentials. This declares who must authenticate; it does not by itself declare whether OAuth is used, whether list_accounts is exposed, or which sync_accounts modes are supported. When true, operators authenticate independently with the seller and account-scoped calls use seller-assigned account_id values because the seller or upstream platform owns the canonical account namespace. If a credential may access more than one account, the seller MUST expose list_accounts and buyers MUST resolve an explicit account_id before the first account-scoped request. If a credential is bound to exactly one account, the seller SHOULD expose list_accounts returning that singleton; a seller MAY omit list_accounts only when it provides the same explicit account_id through another declared path or out-of-band onboarding. When false (default, buyer-declared accounts), the seller trusts the agent's identity claims and account-scoped calls use the natural key. The seller normally provisions through sync_accounts, but MAY lazily provision on the first account-scoped request when billing and other required settings are unambiguous from capabilities or onboarding defaults. A lazy-provisioning seller MUST keep accepting the natural key and MUST expose list_accounts for recovery; if buyer input is needed before use, the seller MUST expose sync_accounts.", + "description": "Whether the seller requires operator-level credentials. This declares who must authenticate; it does not by itself declare whether OAuth is used, whether list_accounts is exposed, or which sync_accounts modes are supported. When true, operators authenticate independently with the seller and account-scoped calls use seller/storefront-assigned account_id values because the seller or upstream platform owns the canonical account namespace. If a credential may access more than one account, the seller MUST expose list_accounts and buyers MUST resolve an explicit account_id before the first account-scoped request. If a credential is bound to exactly one account, the seller SHOULD expose list_accounts returning that singleton; a seller MAY omit list_accounts only when it provides the same explicit account_id through another declared path or out-of-band onboarding. When false (default, buyer-declared accounts), the seller trusts the agent's identity claims and account-scoped calls use the advertiser natural key: brand + operator + optional operator_unit, fixed currency, and sandbox. operator_unit.id is owned by the operator and is distinct from the seller's account_id. The seller normally provisions through sync_accounts, but MAY lazily provision on the first account-scoped request when billing and other required settings are unambiguous from capabilities or onboarding defaults. A lazy-provisioning seller MUST keep accepting the natural key and MUST expose list_accounts for recovery; if buyer input is needed before use, the seller MUST expose sync_accounts.", "default": false }, "authorization_endpoint": { diff --git a/tests/composed-schema-validation.test.cjs b/tests/composed-schema-validation.test.cjs index b5dbb91aa0..60719d6617 100644 --- a/tests/composed-schema-validation.test.cjs +++ b/tests/composed-schema-validation.test.cjs @@ -3067,40 +3067,43 @@ async function runTests() { { idempotency_key: 'request-proposals-natural-account-0001', account: { - brand: { domain: 'acmeoutdoor.example', market: 'NL' }, + brand: { domain: 'acmeoutdoor.example', countries: ['NL'] }, operator: 'buyer.example', - operator_region: 'emea', + operator_unit: { id: '234284238', name: 'Acme EMEA' }, + currency: 'EUR', sandbox: true }, brief: 'Reach streaming audio listeners in Rome' }, - 'request_proposals accepts market and operator-region qualifiers in its natural account key' + 'request_proposals accepts country, operator-unit, and currency qualifiers in its natural account key' ); await testSchemaRejection( '/schemas/media-buy/request-proposals-request.json', { - idempotency_key: 'request-proposals-invalid-market-0001', + idempotency_key: 'request-proposals-invalid-account-key-0001', account: { - brand: { domain: 'acmeoutdoor.example', market: 'nl' }, + brand: { domain: 'acmeoutdoor.example', countries: ['nl'] }, operator: 'buyer.example', - operator_region: 'EMEA' + operator_unit: { id: '234284238', name: 'Acme EMEA' }, + currency: 'eur' }, brief: 'Reach streaming audio listeners in Rome' }, - 'request_proposals rejects non-canonical market and operator-region identifiers' + 'request_proposals rejects non-canonical country and currency identifiers' ); await testSchemaValidation( '/schemas/account/sync-accounts-request.json', { - idempotency_key: 'sync-accounts-regional-0001', + idempotency_key: 'sync-accounts-operator-unit-0001', accounts: [{ - brand: { domain: 'nova-athletics.example', market: 'NL' }, + brand: { domain: 'nova-athletics.example', countries: ['NL'] }, operator: 'nova-athletics.example', - operator_region: 'emea', + operator_unit: { id: '234284238', name: 'Nova EMEA' }, + currency: 'EUR', billing: 'operator' }] }, - 'sync_accounts provisions the same market and operator-region natural key used by compact tools' + 'sync_accounts provisions the same advertiser natural key used by compact tools' ); await testSchemaRejection( '/schemas/media-buy/request-proposals-request.json', From 7f3c13c75ff5bf22772f01e6fecc25b796092299 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 14 Aug 2026 13:39:12 +0200 Subject: [PATCH 47/49] feat(accounts): define advertiser account currency modes --- .agents/sdk-shim-ledger.json | 5 +- .changeset/secure-get-products-idempotency.md | 2 +- docs/accounts/overview.mdx | 5 +- docs/accounts/provisioning-walkthrough.mdx | 3 +- docs/accounts/tasks/sync_accounts.mdx | 8 ++-- .../by-layer/L2/accounts-and-agents.mdx | 28 +++++++++-- docs/media-buy/specification.mdx | 3 +- docs/protocol/capabilities-explorer.mdx | 3 +- docs/protocol/get_adcp_capabilities.mdx | 8 +++- docs/reference/migration/channels.mdx | 3 +- docs/reference/migration/geo-targeting.mdx | 3 +- docs/sponsored-intelligence/networks.mdx | 1 + docs/sponsored-intelligence/workflow.mdx | 1 + server/src/training-agent/task-handlers.ts | 1 + server/tests/unit/account-handlers.test.ts | 46 +++++++++++++++++++ server/tests/unit/training-agent.test.ts | 1 + .../universal/billing-gate-dispatch.yaml | 8 +++- .../source/account/sync-accounts-request.json | 6 +-- .../source/enums/account-currency-mode.json | 12 +++++ .../schemas/source/enums/billing-party.json | 2 +- .../get-adcp-capabilities-response.json | 11 ++++- tests/composed-schema-validation.test.cjs | 30 +++++++++++- tests/patch-sdk-rc15.test.cjs | 25 ++++++++++ 23 files changed, 188 insertions(+), 27 deletions(-) create mode 100644 static/schemas/source/enums/account-currency-mode.json diff --git a/.agents/sdk-shim-ledger.json b/.agents/sdk-shim-ledger.json index 94a869ba55..a65870e564 100644 --- a/.agents/sdk-shim-ledger.json +++ b/.agents/sdk-shim-ledger.json @@ -24,12 +24,13 @@ "owner": "compliance", "upstream": "adcontextprotocol/adcp-client#2105", "problem": "The SDK package ships a snapshot of compliance bundles, schema bundles, and generated validators. Current PR storyboard runs need to grade current repo source before a new protocol bundle exists.", - "localBehavior": "Build current compliance/schema bundles, copy them into the SDK cache, and patch generated validators for same-PR controller enums and request/response schema additions.", + "localBehavior": "Build current compliance/schema bundles, copy them into the SDK cache, and patch generated validators for same-PR controller enums and request/response schema additions. The patch regression suite also loads the installed generated response validator to prove the additive account response remains consumable by the pinned 3.1 SDK.", "releaseFollowUp": "This overlay is validation-only and does not publish generated types. After the protocol release, regenerate and publish @adcp/sdk, then explicitly upgrade pinned downstream consumers including agentic-api.", "removalCondition": "Remove when the storyboard matrix passes current compliance and schema roots through public SDK options instead of overlaying the SDK cache.", "paths": [ "scripts/overlay-compliance-cache.sh", - "scripts/run-storyboards-matrix.sh" + "scripts/run-storyboards-matrix.sh", + "tests/patch-sdk-rc15.test.cjs" ], "terms": [ "node_modules/@adcp/sdk/compliance/cache", diff --git a/.changeset/secure-get-products-idempotency.md b/.changeset/secure-get-products-idempotency.md index 9e2fe08225..4236d4b961 100644 --- a/.changeset/secure-get-products-idempotency.md +++ b/.changeset/secure-get-products-idempotency.md @@ -5,4 +5,4 @@ Add the compact AdCP 3.2 product and MediaBuy lifecycle: `list_products`, `request_proposals`, `refine_proposals`, `decline_proposals`, `buy_products`, `accept_proposal`, and `control_media_buy`. The task-specific contracts separate offer discovery, immutable draft proposal creation, explicit finalization with inventory reservation, terminal decline, direct purchase, proposal acceptance, and operational delivery control while retaining `get_products`, `create_media_buy`, and `update_media_buy` as compatibility facades throughout 3.x. New purchase and control inputs never accept inline creatives; commercial amendments and negotiated cancellations fork an accepted proposal, while operational controls remain revision-checked. Compact purchase snapshots preserve resolved package flight, billing-measurement, performance, and reporting terms. A shared opportunity reference connects planning-cycle context through purchase without duplicating proposal version identity. Canonical inputs use stable brand keys, catalog references, and compact account and optimization types so brand assets, legacy named formats, creative provenance, and compliance payloads do not transitively enter the clean tools. Publish machine-readable SDK fallback grades and task-result schema resolution, plus MCP production, media-buy, and creative catalogs that remove presentation annotations without changing validation semantics. The role catalogs select active 3.2 seller-hosted operations and publish client-side input-only prompt views while retaining output and terminal task-result schemas in their parent validation catalogs. Preserve the AdCP 3.1 inventory-reservation contract in the compact lifecycle: requested and revised proposals remain immutable drafts until `refine_proposals` finalizes them, and a finalized `committed` snapshot guarantees inventory is held until `expires_at`. Add `countries` and `property_list` product-attribute filters, and publish compact task-specific async envelopes for consultative proposal planning and re-underwriting. -Let compact BrandKeys qualify commercial advertiser identity with canonical `countries[]` without turning identity into delivery targeting. Extend buyer-declared natural accounts with an operator-owned unit (`id` plus mutable display `name`), optional immutable account currency, and sandbox identity. These fields round-trip through `sync_accounts` and `list_accounts`; the operator unit remains explicitly distinct from the seller/storefront `account_id`. +Let compact BrandKeys qualify commercial advertiser identity with canonical `countries[]` without turning identity into delivery targeting. Extend buyer-declared natural accounts with an operator-owned unit (`id` plus mutable display `name`), optional immutable account currency, and sandbox identity. These fields round-trip through `sync_accounts` and `list_accounts`; the operator unit remains explicitly distinct from the seller/storefront `account_id`. Require sellers to advertise fixed versus per-media-buy account currency support, and define only the BrandKey projection of a compatibility BrandRef as account identity. diff --git a/docs/accounts/overview.mdx b/docs/accounts/overview.mdx index ecf13429f6..1a2ec99070 100644 --- a/docs/accounts/overview.mdx +++ b/docs/accounts/overview.mdx @@ -18,6 +18,7 @@ Seven questions underlie every AdCP transaction: | How does the operator authenticate? | Seller capabilities | `require_operator_auth` determines who must authenticate and which account reference shape is expected. | | What am I allowed to do on this account? | Caller scope | The `authorization` object on each per-account entry in [`sync_accounts`](/docs/accounts/tasks/sync_accounts) and [`list_accounts`](/docs/accounts/tasks/list_accounts) responses describes `allowed_tasks`, `field_scopes`, `scope_name`, and `read_only` for the calling agent. See [Caller authorization](#caller-authorization) below. | | Who gets billed? | Account terms | Buyer passes `billing` in `sync_accounts` — `operator`, `agent`, or `advertiser` — when a choice is needed. A lazy-provisioning seller uses an unambiguous capability or onboarding default. | +| Where is currency selected? | Seller capabilities | `supported_account_currency_modes` declares whether `currency` is fixed on the advertiser account, selected per media buy, or both. | | What was consumed? | Usage reporting | `report_usage` informs vendor agents how their services were used after delivery | The seller declares the account model in [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities) via `require_operator_auth`. That field declares who must authenticate; it does not by itself declare whether OAuth is used, whether `list_accounts` is exposed, or which `sync_accounts` modes are supported. @@ -43,7 +44,9 @@ The Accounts Protocol applies across all vendor protocols. An orchestrator estab | Governance | Content standards billing | | Creative | Creative service billing | -The account reference may be a seller/storefront-assigned `account_id` (seller-owned namespaces, usually `require_operator_auth: true`) or a buyer-declared advertiser key — `brand` + `operator` + optional `operator_unit`, `currency`, and `sandbox` (`require_operator_auth: false`). The brand may include `countries[]` to distinguish commercial advertiser entities without specifying delivery targeting. `operator_unit.id` identifies an operator-owned unit or platform seat; it is deliberately separate from the seller's `account_id`. For buyer-declared accounts, the complete natural-key `AccountRef` MUST remain valid on subsequent calls and round-trip through `list_accounts`, even if the seller also echoes an internal `account_id`. See [Account references](/docs/building/by-layer/L2/accounts-and-agents#account-references) for details. +The account reference may be a seller/storefront-assigned `account_id` (seller-owned namespaces, usually `require_operator_auth: true`) or a buyer-declared advertiser key — `brand` + `operator` + optional `operator_unit`, `currency`, and `sandbox` (`require_operator_auth: false`). In this vocabulary, **brand identity** is the BrandKey projection (`domain`, optional `brand_id`, canonicalized `countries[]`); **operator identity** is the operator domain plus optional `operator_unit.id`; and **advertiser account identity** is the complete natural key, including currency and sandbox disposition. Mutable `operator_unit.name` and broader BrandRef overrides are not identity. `operator_unit.id` is deliberately separate from the seller's `account_id`. For buyer-declared accounts, the complete natural-key `AccountRef` MUST remain valid on subsequent calls and round-trip through `list_accounts`, even if the seller also echoes an internal `account_id`. See [Account references](/docs/building/by-layer/L2/accounts-and-agents#account-references) for details. + +`billing` names the party the seller invoices for this account relationship. It is not a payment rail or settlement selector. AdCP does not currently standardize per-media-buy choice between an intermediary clearing flow and direct settlement; implementations must not encode that distinction by silently changing the meaning of `billing`. ## Account Status Lifecycle diff --git a/docs/accounts/provisioning-walkthrough.mdx b/docs/accounts/provisioning-walkthrough.mdx index fdca6998b1..cd2d0bba7f 100644 --- a/docs/accounts/provisioning-walkthrough.mdx +++ b/docs/accounts/provisioning-walkthrough.mdx @@ -86,6 +86,7 @@ Sam calls `get_adcp_capabilities` before choosing an account workflow. The relev "account": { "require_operator_auth": false, "supported_billing": ["operator", "agent", "advertiser"], + "supported_account_currency_modes": ["fixed", "per_media_buy"], "notifications": { "supported": true, "registration_task": "sync_accounts", @@ -97,7 +98,7 @@ Sam calls `get_adcp_capabilities` before choosing an account workflow. The relev } ``` -`require_operator_auth: false` selects buyer-declared provisioning. `supported_billing` tells Sam which billing values the seller accepts at the capability level; it does not guarantee that every authenticated buyer agent is commercially authorized for every advertised value. +`require_operator_auth: false` selects buyer-declared provisioning. `supported_billing` tells Sam which invoiced parties the seller accepts at the capability level; it does not guarantee that every authenticated buyer agent is commercially authorized for every advertised value. `supported_account_currency_modes` tells Sam whether to include one immutable account `currency`, omit it for per-media-buy selection, or choose either model. The `notifications` block selects the preferred observation strategy: register a durable subscriber during provisioning, treat each webhook as an invalidation signal, and repair from `list_accounts`. If the block is absent or `supported` is `false`, poll instead. diff --git a/docs/accounts/tasks/sync_accounts.mdx b/docs/accounts/tasks/sync_accounts.mdx index ced9b505b3..9eaaf7f75b 100644 --- a/docs/accounts/tasks/sync_accounts.mdx +++ b/docs/accounts/tasks/sync_accounts.mdx @@ -95,17 +95,17 @@ asyncio.run(main()) | Field | Type | Required | Description | |-------|------|----------|-------------| -| `brand` | object | Yes | Brand reference identifying the advertiser. Contains `domain`, optional `brand_id`, and optional ISO 3166-1 alpha-2 `countries[]`. Countries qualify the commercial advertiser identity; they do not target delivery. See [brand-ref](/docs/brand-protocol/brand-json). | +| `brand` | object | Yes | Brand reference identifying the advertiser. Only its BrandKey fields — `domain`, optional `brand_id`, and the canonicalized ISO 3166-1 alpha-2 `countries[]` set — participate in account identity. Countries qualify commercial identity; they do not target delivery. The existing 3.x task still accepts broader BrandRef fields for compatibility, but mutable/per-call fields such as `industries`, `data_subject_contestation`, and `brand_kit_override` never affect lookup or upsert identity. New producers should send only BrandKey fields. | | `operator` | string | Yes | Domain of the entity operating on the brand's behalf (e.g. `pinnacle-media.com`). When the brand operates directly, set to the brand's domain. Verified against the brand's `authorized_operators` in brand.json. | | `operator_unit` | object | No | Operator-owned business unit, agency seat, or buying-platform account. `id` is the stable operator-assigned identifier and participates in the natural key; optional `name` is mutable display metadata and does not. This is not the seller's `account_id`. | -| `currency` | string | No | Immutable ISO 4217 transaction currency when the seller's advertiser object is currency-bound. It participates in the natural key and every media buy on the account must use it. Omit it when the seller supports choosing currency per media buy. | -| `billing` | string | Yes | Who should be invoiced: `operator`, `agent`, or `advertiser`. Check [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities) for `supported_billing` to see what the seller accepts at the capability level. The seller must either accept this billing model or reject the request. Sellers MAY additionally reject a value the seller-wide capability accepts when the calling buyer agent's commercial relationship does not permit it — e.g., a buyer agent onboarded as passthrough-only (no payments relationship — only the operator can be invoiced). The two gates use distinct error codes — [`BILLING_NOT_SUPPORTED`](/docs/building/verification/compliance-catalog#error-code-billing-not-supported) for the seller-wide capability gate, [`BILLING_NOT_PERMITTED_FOR_AGENT`](/docs/building/verification/compliance-catalog#error-code-billing-not-permitted-for-agent) for the per-buyer-agent gate — so agents can dispatch on autonomous-retry vs human-onboarding without parsing prose. See [Buyer-agent identity](/docs/building/by-layer/L2/accounts-and-agents#buyer-agent-identity) and [Billing and Account Setup](/docs/building/by-layer/L3/error-handling#billing-and-account-setup). | +| `currency` | string | No | Immutable ISO 4217 transaction currency when the seller's advertiser object is currency-bound. Check `account.supported_account_currency_modes`: include `currency` for `fixed`; omit it for `per_media_buy`. A supplied currency participates in the natural key and every media buy on the account must use it. | +| `billing` | string | Yes | Who the seller invoices for this buyer–storefront account relationship: `operator`, `agent`, or `advertiser`. Check [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities) for `supported_billing`. The seller must accept the requested invoiced party exactly or reject the request. This field does not choose a payment rail, clearing intermediary, or per-media-buy settlement route; AdCP payment and settlement remain out of protocol. Sellers MAY additionally reject a seller-wide supported value when the calling buyer agent's commercial relationship does not permit it. See [`BILLING_NOT_SUPPORTED`](/docs/building/verification/compliance-catalog#error-code-billing-not-supported), [`BILLING_NOT_PERMITTED_FOR_AGENT`](/docs/building/verification/compliance-catalog#error-code-billing-not-permitted-for-agent), [Buyer-agent identity](/docs/building/by-layer/L2/accounts-and-agents#buyer-agent-identity), and [Billing and Account Setup](/docs/building/by-layer/L3/error-handling#billing-and-account-setup). | | `billing_entity` | object | No | Structured business entity details for the party responsible for payment. Contains `legal_name` (required), plus optional `vat_id`, `tax_id`, `registration_number`, `address`, `contacts`, and `bank`. Bank details are write-only — included in requests but never echoed in responses. See [billing entity and invoice recipient](/docs/building/by-layer/L2/accounts-and-agents#billing-entity-and-invoice-recipient). | | `payment_terms` | string | No | Payment terms for this account: `net_15`, `net_30`, `net_45`, `net_60`, `net_90`, or `prepay`. The seller must either accept these terms or reject the account — terms are never silently remapped. When omitted, the seller applies its default terms. | | `sandbox` | boolean | No | When true, set up a sandbox account with no real platform calls or billing. Only applicable to buyer-declared accounts (`require_operator_auth: false`). For account-id namespaces, sandbox accounts are pre-existing test accounts discovered via [`list_accounts`](/docs/accounts/tasks/list_accounts) or supplied out-of-band. | | `notification_configs` | array | No | Account-level webhook subscribers for events that outlive any single media buy: creative lifecycle notifications, account status changes, and wholesale feed change webhooks. Omit to leave existing subscribers unchanged; send `[]` to remove all subscribers; send a full array to replace. Entries are keyed by account-scoped `subscriber_id`; an existing `subscriber_id` is upserted, and persisted IDs absent from the sent array are removed. | -**Natural key**: The tuple `(brand, operator, operator_unit.id, currency, sandbox)` identifies the advertiser object in the seller system. The nested brand contains `domain`, optional `brand_id`, and optional `countries[]`. Country order has no semantic meaning; send unique uppercase codes in lexical order for stable keys and signatures. `operator_unit.name` is not identity, so renaming a seat does not create a new account. +**Natural key**: The tuple `(brand, operator, operator_unit.id, currency, sandbox)` identifies the advertiser object in the seller system. Within `brand`, only `domain`, `brand_id`, and canonicalized `countries[]` participate. Country order has no semantic meaning; send unique uppercase codes in lexical order for stable keys and signatures. `operator_unit.name` and all mutable BrandRef fields are outside identity, so changing display or per-call metadata does not create a new account. For example, the following key identifies Nova's NL/DE advertiser object, operated through Pinnacle's EMEA seat and fixed to EUR. Adding `sandbox: true` identifies a separate test account. diff --git a/docs/building/by-layer/L2/accounts-and-agents.mdx b/docs/building/by-layer/L2/accounts-and-agents.mdx index 6b23fb1a06..1fe7d3cef6 100644 --- a/docs/building/by-layer/L2/accounts-and-agents.mdx +++ b/docs/building/by-layer/L2/accounts-and-agents.mdx @@ -37,7 +37,20 @@ The buyer must pass one of these values as `billing` in every `sync_accounts` en | `agent` | Agent | Agent consolidates billing across brands | | `advertiser` | Advertiser directly | Operator places orders but advertiser pays (common on social platforms and in DACH B2B workflows) | -**2. Do you require operator-level auth?** (`require_operator_auth`) +This is an account-level invoiced-party choice. It does not select a payment rail, clearing intermediary, or settlement route for an individual media buy; AdCP payment and settlement remain out of protocol. + +**2. Which account currency models do you support?** (`supported_account_currency_modes`) + +AdCP 3.2 sellers advertise one or both values: + +| Mode | Provisioning behavior | +|------|------------------------| +| `fixed` | Include one immutable ISO 4217 `currency`; it participates in the advertiser account natural key. | +| `per_media_buy` | Omit account `currency`; select currency on each proposal or media buy. | + +When both are present, the buyer selects the model by including or omitting `currency`. + +**3. Do you require operator-level auth?** (`require_operator_auth`) This field determines the authentication model and the account reference shape: @@ -55,7 +68,8 @@ Sellers can also declare `account_financials: true` to expose account-level fina { "account": { "require_operator_auth": false, - "supported_billing": ["operator", "agent"] + "supported_billing": ["operator", "agent"], + "supported_account_currency_modes": ["fixed", "per_media_buy"] } } ``` @@ -66,7 +80,8 @@ Sellers that support `advertiser` billing declare it explicitly: { "account": { "require_operator_auth": false, - "supported_billing": ["operator", "agent", "advertiser"] + "supported_billing": ["operator", "agent", "advertiser"], + "supported_account_currency_modes": ["fixed", "per_media_buy"] } } ``` @@ -94,6 +109,7 @@ The operator already has an account on the platform — an ad account, a busines "account": { "require_operator_auth": true, "supported_billing": ["operator"], + "supported_account_currency_modes": ["fixed"], "authorization_endpoint": "https://seller.example.com/oauth/authorize" } } @@ -146,7 +162,8 @@ Many publishers also accept agent billing (`supported_billing: ["operator", "age ```json { "account": { - "supported_billing": ["operator", "agent"] + "supported_billing": ["operator", "agent"], + "supported_account_currency_modes": ["fixed", "per_media_buy"] } } ``` @@ -238,7 +255,8 @@ All billing flows through the agent. The agent has a standing relationship with ```json { "account": { - "supported_billing": ["agent"] + "supported_billing": ["agent"], + "supported_account_currency_modes": ["per_media_buy"] } } ``` diff --git a/docs/media-buy/specification.mdx b/docs/media-buy/specification.mdx index 6e85c48a4c..a9aceaf26d 100644 --- a/docs/media-buy/specification.mdx +++ b/docs/media-buy/specification.mdx @@ -51,7 +51,8 @@ Sales agents MUST declare Media Buy Protocol support via `get_adcp_capabilities` }, "supported_protocols": ["media_buy"], "account": { - "supported_billing": ["operator", "agent"] + "supported_billing": ["operator", "agent"], + "supported_account_currency_modes": ["fixed", "per_media_buy"] } } ``` diff --git a/docs/protocol/capabilities-explorer.mdx b/docs/protocol/capabilities-explorer.mdx index b647a24956..72a303db36 100644 --- a/docs/protocol/capabilities-explorer.mdx +++ b/docs/protocol/capabilities-explorer.mdx @@ -55,7 +55,8 @@ How accounts are negotiated; whether one is required before product discovery; w - **`required_for_products`** — boolean. If true, [`get_products`](/docs/media-buy/task-reference/get_products) requires an established account. - **`authorization_endpoint`** — OAuth/auth URL for account negotiation. - **`require_operator_auth`** — declares whether operator-level authentication is required. -- **`supported_billing`** — array of billing models (e.g. `prepaid`, `monthly_invoice`). +- **`supported_billing`** — invoiced parties the seller supports (`operator`, `agent`, `advertiser`). +- **`supported_account_currency_modes`** — whether advertiser accounts use an immutable `fixed` currency, `per_media_buy` currency, or both. - **`account_financials`** — what financial data the seller exposes (credit limits, current balance, etc.). - **`sandbox`** — sandbox-account capabilities. diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index 06da581a86..a98e1ccd15 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -344,6 +344,7 @@ Account and authentication capabilities. All sellers should declare this section | Field | Type | Description | |-------|------|-------------| | `supported_billing` | string[] | **Required.** Billing models this seller supports: `operator`, `agent`, or `advertiser`. When the buyer calls `sync_accounts`, every entry must select one of these values. | +| `supported_account_currency_modes` | string[] | **Required in 3.2.** Advertiser-account currency models this seller supports: `fixed`, `per_media_buy`, or both. A buyer includes `currency` in `sync_accounts` for `fixed` and omits it for `per_media_buy`. When both are advertised, presence or absence selects the model. | | `require_operator_auth` | boolean | Default: `false`. Declares who must authenticate; it does not by itself declare whether OAuth is used, whether `list_accounts` is exposed, or which `sync_accounts` modes are supported. When `true`, each operator authenticates independently and account-scoped calls use seller/storefront-assigned `account_id` values. When `false`, the agent is trusted and calls use the advertiser natural key: `brand` + `operator` + optional `operator_unit`, fixed `currency`, and `sandbox`. `operator_unit.id` belongs to the operator and is not the seller's `account_id`. Buyer-declared sellers normally provision through `sync_accounts`, MUST continue accepting the natural key, and SHOULD expose `list_accounts` so all key fields can be recovered after a cold start. | | `authorization_endpoint` | string | OAuth URL for operator authentication. Present when the seller supports OAuth for operator authentication. Relevant when `require_operator_auth: true`; if absent, operators obtain credentials out-of-band (seller portal, API key). | | `required_for_products` | boolean | Default: `false`. When `true`, the buyer must establish an account before calling [`get_products`](/docs/media-buy/task-reference/get_products). When `false`, the buyer can browse products without an account — useful for price comparison and discovery before committing to a seller. | @@ -359,6 +360,7 @@ Declares whether the seller supports durable account lifecycle invalidation webh { "account": { "supported_billing": ["operator", "agent"], + "supported_account_currency_modes": ["fixed", "per_media_buy"], "notifications": { "supported": true, "registration_task": "sync_accounts", @@ -1392,7 +1394,8 @@ const buy = await client.createMediaBuy({ "supported_protocols": ["media_buy"], "account": { "require_operator_auth": false, - "supported_billing": ["operator", "agent"] + "supported_billing": ["operator", "agent"], + "supported_account_currency_modes": ["fixed", "per_media_buy"] }, "media_buy": { "creative_approval_mode": "auto_approve", @@ -1543,7 +1546,8 @@ An agent can implement multiple protocols from a single endpoint. This is common "supported_protocols": ["media_buy", "creative"], "account": { "require_operator_auth": false, - "supported_billing": ["operator"] + "supported_billing": ["operator"], + "supported_account_currency_modes": ["fixed"] }, "media_buy": { "creative_approval_mode": "require_human", diff --git a/docs/reference/migration/channels.mdx b/docs/reference/migration/channels.mdx index 9d12f1993d..03972858a8 100644 --- a/docs/reference/migration/channels.mdx +++ b/docs/reference/migration/channels.mdx @@ -112,7 +112,8 @@ v3 products declare an array of channels, so a single product can span multiple }, "supported_protocols": ["media_buy"], "account": { - "supported_billing": ["operator", "agent"] + "supported_billing": ["operator", "agent"], + "supported_account_currency_modes": ["fixed", "per_media_buy"] }, "media_buy": { "portfolio": { diff --git a/docs/reference/migration/geo-targeting.mdx b/docs/reference/migration/geo-targeting.mdx index 011ea0e263..033fcdc44d 100644 --- a/docs/reference/migration/geo-targeting.mdx +++ b/docs/reference/migration/geo-targeting.mdx @@ -133,7 +133,8 @@ Before sending geo targeting, buyers should verify the seller supports the reque }, "supported_protocols": ["media_buy"], "account": { - "supported_billing": ["operator", "agent"] + "supported_billing": ["operator", "agent"], + "supported_account_currency_modes": ["fixed", "per_media_buy"] }, "media_buy": { "execution": { diff --git a/docs/sponsored-intelligence/networks.mdx b/docs/sponsored-intelligence/networks.mdx index a3351ff84c..0384fea22e 100644 --- a/docs/sponsored-intelligence/networks.mdx +++ b/docs/sponsored-intelligence/networks.mdx @@ -76,6 +76,7 @@ Declare capabilities accordingly: "account": { "require_operator_auth": false, "supported_billing": ["operator", "agent"], + "supported_account_currency_modes": ["fixed", "per_media_buy"], "required_for_products": false, "sandbox": true } diff --git a/docs/sponsored-intelligence/workflow.mdx b/docs/sponsored-intelligence/workflow.mdx index fc02218035..e8e27167ab 100644 --- a/docs/sponsored-intelligence/workflow.mdx +++ b/docs/sponsored-intelligence/workflow.mdx @@ -22,6 +22,7 @@ Start by checking the seller's capabilities to understand the account model. Fir "account": { "require_operator_auth": true, "supported_billing": ["operator"], + "supported_account_currency_modes": ["fixed"], "authorization_endpoint": "https://ads.ai-platform.example.com/oauth/authorize", "required_for_products": false, "sandbox": true diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 83d5fe31dc..5db46be930 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -10156,6 +10156,7 @@ export async function handleGetAdcpCapabilities(args: ToolArgs, ctx: TrainingCon // becomes a regular string array that the JSON-Schema // validator on the capabilities response accepts. supported_billing: [...SUPPORTED_BILLINGS], + supported_account_currency_modes: ['fixed', 'per_media_buy'], sandbox: true, }, ...(wholesaleProfile.signalWholesale && { diff --git a/server/tests/unit/account-handlers.test.ts b/server/tests/unit/account-handlers.test.ts index 9c117ad14a..2cdde1bd3c 100644 --- a/server/tests/unit/account-handlers.test.ts +++ b/server/tests/unit/account-handlers.test.ts @@ -170,6 +170,52 @@ describe('sync_accounts', () => { ])); }); + it('uses only BrandKey fields from a broader 3.x BrandRef for account identity', async () => { + const base = { + operator: 'pinnacle-agency.example', + operator_unit: { id: 'seat_emea_01', name: 'EMEA' }, + currency: 'EUR', + billing: 'operator', + sandbox: true, + }; + const { result: first } = await simulateCallTool(server, 'sync_accounts', { + adcp_version: '3.1', + accounts: [{ + ...base, + brand: { + domain: 'nova-athletics.example', + brand_id: 'running', + countries: ['DE', 'NL'], + industries: ['sports'], + }, + }], + }); + const firstAccount = (first.accounts as Record[])[0]; + + const { result: second } = await simulateCallTool(server, 'sync_accounts', { + adcp_version: '3.1', + accounts: [{ + ...base, + brand: { + domain: 'nova-athletics.example', + brand_id: 'running', + countries: ['DE', 'NL'], + industries: ['retail'], + brand_kit_override: { tagline: 'A mutable campaign tagline' }, + }, + }], + }); + const secondAccount = (second.accounts as Record[])[0]; + + expect(firstAccount.action).toBe('created'); + expect(secondAccount.action).toBe('updated'); + expect(secondAccount.account_id).toBe(firstAccount.account_id); + expect(secondAccount).toHaveProperty('brand'); + expect(secondAccount).toHaveProperty('operator', 'pinnacle-agency.example'); + expect(secondAccount).not.toHaveProperty('operator_identity'); + expect(secondAccount).not.toHaveProperty('advertiser_identity'); + }); + it('non-sandbox account is pending_approval with setup URL', async () => { const { result } = await simulateCallTool(server, 'sync_accounts', { accounts: [{ diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index e5f56ab8db..34ab672612 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -1566,6 +1566,7 @@ describe('createTrainingAgentServer', () => { expect(caps.account).toBeDefined(); const account = caps.account as Record; expect((account.supported_billing as unknown[]).length).toBeGreaterThan(0); + expect(account.supported_account_currency_modes).toEqual(['fixed', 'per_media_buy']); // portfolio present expect(mediaBuy.portfolio).toBeDefined(); diff --git a/static/compliance/source/universal/billing-gate-dispatch.yaml b/static/compliance/source/universal/billing-gate-dispatch.yaml index 558cb3e07c..6c4516e4a5 100644 --- a/static/compliance/source/universal/billing-gate-dispatch.yaml +++ b/static/compliance/source/universal/billing-gate-dispatch.yaml @@ -160,7 +160,8 @@ phases: expected: | Return capabilities including `account.supported_billing` listing which `billing` values the seller accepts at the wire-capability - level. + level and `account.supported_account_currency_modes` declaring + fixed versus per-media-buy account currency support. sample_request: context: @@ -169,6 +170,8 @@ phases: context_outputs: - path: "account.supported_billing" key: "supported_billing" + - path: "account.supported_account_currency_modes" + key: "supported_account_currency_modes" validations: - check: response_schema @@ -176,6 +179,9 @@ phases: - check: field_present path: "account.supported_billing" description: "Seller declares supported_billing" + - check: field_present + path: "account.supported_account_currency_modes" + description: "Seller declares supported account currency modes" - check: field_present path: "context" description: "Response echoes back the context object" diff --git a/static/schemas/source/account/sync-accounts-request.json b/static/schemas/source/account/sync-accounts-request.json index f0ddefc70f..21da0f0d0a 100644 --- a/static/schemas/source/account/sync-accounts-request.json +++ b/static/schemas/source/account/sync-accounts-request.json @@ -31,7 +31,7 @@ }, "brand": { "$ref": "/schemas/core/brand-ref.json", - "description": "Brand reference identifying the advertiser. Required for **provisioning mode**; MUST be absent in settings-update mode." + "description": "Brand reference identifying the advertiser. Required for **provisioning mode**; MUST be absent in settings-update mode. Only the BrandKey projection — `domain`, `brand_id`, and the canonicalized `countries[]` set — participates in account identity. Mutable or per-call BrandRef fields such as `industries`, `data_subject_contestation`, and `brand_kit_override` MUST NOT affect lookup, idempotency, or account creation. New 3.2 producers SHOULD send only the BrandKey fields; the broader BrandRef remains accepted on this existing 3.x task for compatibility." }, "operator": { "type": "string", @@ -45,11 +45,11 @@ "currency": { "type": "string", "pattern": "^[A-Z]{3}$", - "description": "Optional immutable ISO 4217 transaction currency for a currency-bound advertiser object. When supplied, it participates in the natural key and all media buys on the account use it. Omit for per-media-buy currency selection. MUST be absent in settings-update mode." + "description": "Optional immutable ISO 4217 transaction currency for a currency-bound advertiser object. Consult `account.supported_account_currency_modes` before provisioning. When supplied, it participates in the natural key and all media buys on the account use it. Omit for per-media-buy currency selection. MUST be absent in settings-update mode." }, "billing": { "$ref": "/schemas/enums/billing-party.json", - "description": "Who should be invoiced. Required for **provisioning mode**; MUST be absent in settings-update mode (billing is fixed at provisioning time and cannot be changed via settings-update)." + "description": "Who the seller invoices for this buyer–storefront account relationship. Required for **provisioning mode**; MUST be absent in settings-update mode (the invoiced party is fixed at provisioning time and cannot be changed via settings-update). This field does not select a payment rail, clearing intermediary, or per-media-buy settlement route." }, "billing_entity": { "$ref": "/schemas/core/business-entity.json", diff --git a/static/schemas/source/enums/account-currency-mode.json b/static/schemas/source/enums/account-currency-mode.json new file mode 100644 index 0000000000..e23c9e8851 --- /dev/null +++ b/static/schemas/source/enums/account-currency-mode.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/enums/account-currency-mode.json", + "title": "Account Currency Mode", + "description": "How a seller binds transaction currency to advertiser accounts.", + "type": "string", + "enum": ["fixed", "per_media_buy"], + "enumDescriptions": { + "fixed": "The advertiser account is bound to one immutable ISO 4217 currency supplied during provisioning; currency participates in the natural account key.", + "per_media_buy": "The advertiser account is not currency-bound; omit account currency and select currency in each proposal or media buy." + } +} diff --git a/static/schemas/source/enums/billing-party.json b/static/schemas/source/enums/billing-party.json index 5e9246dbd3..d6be5778b7 100644 --- a/static/schemas/source/enums/billing-party.json +++ b/static/schemas/source/enums/billing-party.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/enums/billing-party.json", "title": "Billing Party", - "description": "Which party the seller invoices for an account. Determines the billing entity and invoicing flow.", + "description": "Which party the seller invoices for a buyer–storefront account relationship. Determines the billing entity and invoicing flow, not a payment rail, clearing intermediary, or per-media-buy settlement route. AdCP does not perform in-protocol payment or settlement.", "type": "string", "enum": ["operator", "agent", "advertiser"], "enumDescriptions": { diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index 079c75ee87..8e22c11907 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -655,6 +655,15 @@ }, "minItems": 1 }, + "supported_account_currency_modes": { + "type": "array", + "description": "Required in AdCP 3.2. Declares whether advertiser accounts are bound to one immutable currency (`fixed`), select currency independently per proposal or media buy (`per_media_buy`), or support both models. When only `fixed` is advertised, buyer-declared provisioning entries MUST include `currency`. When only `per_media_buy` is advertised, they MUST omit it. When both are advertised, presence of `currency` selects a fixed-currency account and omission selects per-media-buy currency.", + "items": { + "$ref": "/schemas/enums/account-currency-mode.json" + }, + "minItems": 1, + "uniqueItems": true + }, "required_for_products": { "type": "boolean", "description": "Whether an account reference is required for get_products. When true, the buyer must establish an account before browsing products. When false (default), the buyer can browse products without an account — useful for price comparison and discovery before committing to a seller.", @@ -754,7 +763,7 @@ "default": false } }, - "required": ["supported_billing"] + "required": ["supported_billing", "supported_account_currency_modes"] }, "media_buy": { "type": "object", diff --git a/tests/composed-schema-validation.test.cjs b/tests/composed-schema-validation.test.cjs index 60719d6617..7e45dbae8e 100644 --- a/tests/composed-schema-validation.test.cjs +++ b/tests/composed-schema-validation.test.cjs @@ -1665,9 +1665,33 @@ async function runTests() { status: 'completed', adcp: { major_versions: [3] }, supported_protocols: ['media_buy'], - account: { supported_billing: ['operator', 'agent'] } + account: { + supported_billing: ['operator', 'agent'], + supported_account_currency_modes: ['fixed', 'per_media_buy'] + } }; + await testSchemaRejection( + '/schemas/protocol/get-adcp-capabilities-response.json', + { + ...capabilitiesBase, + account: { supported_billing: ['operator', 'agent'] } + }, + 'AdCP 3.2 account capabilities require currency-mode discovery' + ); + + await testSchemaRejection( + '/schemas/protocol/get-adcp-capabilities-response.json', + { + ...capabilitiesBase, + account: { + supported_billing: ['operator'], + supported_account_currency_modes: ['account_default'] + } + }, + 'Account currency modes reject non-standard values' + ); + await testSchemaValidation( '/schemas/protocol/get-adcp-capabilities-response.json', { ...capabilitiesBase, adcp: { ...capabilitiesBase.adcp, idempotency: { supported: true, replay_ttl_seconds: 86400 } } }, @@ -1891,6 +1915,7 @@ async function runTests() { adcp: { ...capabilitiesBase.adcp, idempotency: { supported: true, replay_ttl_seconds: 86400 } }, account: { supported_billing: ['operator', 'agent'], + supported_account_currency_modes: ['fixed', 'per_media_buy'], notifications: { supported: true, registration_task: 'sync_accounts', @@ -1910,6 +1935,7 @@ async function runTests() { adcp: { ...capabilitiesBase.adcp, idempotency: { supported: true, replay_ttl_seconds: 86400 } }, account: { supported_billing: ['operator', 'agent'], + supported_account_currency_modes: ['fixed', 'per_media_buy'], notifications: { supported: false } } }, @@ -1923,6 +1949,7 @@ async function runTests() { adcp: { ...capabilitiesBase.adcp, idempotency: { supported: true, replay_ttl_seconds: 86400 } }, account: { supported_billing: ['operator', 'agent'], + supported_account_currency_modes: ['fixed', 'per_media_buy'], notifications: { supported: true, registration_task: 'sync_accounts', @@ -1940,6 +1967,7 @@ async function runTests() { adcp: { ...capabilitiesBase.adcp, idempotency: { supported: true, replay_ttl_seconds: 86400 } }, account: { supported_billing: ['operator', 'agent'], + supported_account_currency_modes: ['fixed', 'per_media_buy'], notifications: { supported: false, event_types: ['account.status_changed'] diff --git a/tests/patch-sdk-rc15.test.cjs b/tests/patch-sdk-rc15.test.cjs index 8dcc3de026..c4f24ebe78 100644 --- a/tests/patch-sdk-rc15.test.cjs +++ b/tests/patch-sdk-rc15.test.cjs @@ -319,3 +319,28 @@ test('installed SDK uses request-local scoped capabilities for get_products adap } } }); + +test('installed 3.1 SDK accepts the additive flat advertiser natural-key response', async () => { + const schemasPath = path.resolve( + __dirname, + '..', + 'node_modules/@adcp/sdk/dist/lib/types/schemas.generated.js', + ); + const { SyncAccountsResponseSchema } = await import(pathToFileURL(schemasPath).href); + const parsed = SyncAccountsResponseSchema.safeParse({ + status: 'completed', + accounts: [{ + account_id: 'acc_nova_nl', + brand: { domain: 'nova-athletics.example', countries: ['NL'] }, + operator: 'pinnacle-agency.example', + operator_unit: { id: '234284238', name: 'EMEA' }, + currency: 'EUR', + sandbox: true, + action: 'created', + status: 'active', + billing: 'operator', + }], + }); + + assert.equal(parsed.success, true, parsed.success ? undefined : parsed.error.toString()); +}); From 6af26d48cdb903b32ec613e26313cccf78ce8ff2 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 14 Aug 2026 14:12:36 +0200 Subject: [PATCH 48/49] test(accounts): update currency capability fixtures --- tests/example-validation-simple.test.cjs | 6 ++++-- tests/performance-feedback-contract.test.cjs | 5 ++++- tests/relationship-scoped-indicators.test.cjs | 18 ++++++++++++++---- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/tests/example-validation-simple.test.cjs b/tests/example-validation-simple.test.cjs index 6257e42b0c..7652e421ee 100644 --- a/tests/example-validation-simple.test.cjs +++ b/tests/example-validation-simple.test.cjs @@ -1568,7 +1568,8 @@ async function runTests() { "supported_protocols": ["measurement"], "experimental_features": ["measurement.core"], "account": { - "supported_billing": ["operator"] + "supported_billing": ["operator"], + "supported_account_currency_modes": ["fixed", "per_media_buy"] }, "measurement": { "produces_performance_feedback": true, @@ -1613,7 +1614,8 @@ async function runTests() { }, "supported_protocols": ["measurement"], "account": { - "supported_billing": ["operator"] + "supported_billing": ["operator"], + "supported_account_currency_modes": ["fixed", "per_media_buy"] }, "measurement": { "metrics": [ diff --git a/tests/performance-feedback-contract.test.cjs b/tests/performance-feedback-contract.test.cjs index 06fd008d30..5e7a410db4 100644 --- a/tests/performance-feedback-contract.test.cjs +++ b/tests/performance-feedback-contract.test.cjs @@ -245,7 +245,10 @@ describe("compact performance feedback and measurement-agent discovery", () => { supported_versions: ["3.2"], idempotency: { supported: true, replay_ttl_seconds: 86400 }, }, - account: { supported_billing: ["operator"] }, + account: { + supported_billing: ["operator"], + supported_account_currency_modes: ["fixed", "per_media_buy"], + }, }; for (const capabilities of [ diff --git a/tests/relationship-scoped-indicators.test.cjs b/tests/relationship-scoped-indicators.test.cjs index a19a76a6c8..734e18cd0f 100644 --- a/tests/relationship-scoped-indicators.test.cjs +++ b/tests/relationship-scoped-indicators.test.cjs @@ -191,7 +191,8 @@ describe('relationship-scoped indicators', () => { idempotency: { supported: false } }, account: { - supported_billing: ['operator'] + supported_billing: ['operator'], + supported_account_currency_modes: ['fixed', 'per_media_buy'] }, media_buy: { supported_indicator_types: [ @@ -225,7 +226,10 @@ describe('relationship-scoped indicators', () => { status: 'completed', supported_protocols: ['media_buy'], adcp: { major_versions: [3], idempotency: { supported: false } }, - account: { supported_billing: ['operator'] }, + account: { + supported_billing: ['operator'], + supported_account_currency_modes: ['fixed', 'per_media_buy'] + }, media_buy: { supported_indicator_types: ['pacing_risk'] } }; assertValid(validateCapabilities, pollOnlyCapabilities); @@ -237,7 +241,10 @@ describe('relationship-scoped indicators', () => { major_versions: [3], idempotency: { supported: false } }, - account: { supported_billing: ['operator'] }, + account: { + supported_billing: ['operator'], + supported_account_currency_modes: ['fixed', 'per_media_buy'] + }, media_buy: { supported_indicator_types: ['creative_fatigue'], relationship_notifications: { @@ -268,7 +275,10 @@ describe('relationship-scoped indicators', () => { status: 'completed', supported_protocols: ['media_buy'], adcp: { major_versions: [3], idempotency: { supported: false } }, - account: { supported_billing: ['operator'] }, + account: { + supported_billing: ['operator'], + supported_account_currency_modes: ['fixed', 'per_media_buy'] + }, media_buy: { supported_indicator_types: ['budget_constrained'], relationship_notifications: { From d3758812931b07f30d16be2a579e7fbebda65768 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 14 Aug 2026 14:31:18 +0200 Subject: [PATCH 49/49] fix(accounts): preserve 3.1 capability compatibility --- .changeset/secure-get-products-idempotency.md | 2 +- docs/accounts/tasks/sync_accounts.mdx | 2 +- docs/building/by-layer/L2/accounts-and-agents.mdx | 2 +- docs/protocol/capabilities-explorer.mdx | 2 +- docs/protocol/get_adcp_capabilities.mdx | 2 +- .../source/protocol/get-adcp-capabilities-response.json | 4 ++-- tests/composed-schema-validation.test.cjs | 9 +++++++-- 7 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.changeset/secure-get-products-idempotency.md b/.changeset/secure-get-products-idempotency.md index 4236d4b961..e74f56f037 100644 --- a/.changeset/secure-get-products-idempotency.md +++ b/.changeset/secure-get-products-idempotency.md @@ -5,4 +5,4 @@ Add the compact AdCP 3.2 product and MediaBuy lifecycle: `list_products`, `request_proposals`, `refine_proposals`, `decline_proposals`, `buy_products`, `accept_proposal`, and `control_media_buy`. The task-specific contracts separate offer discovery, immutable draft proposal creation, explicit finalization with inventory reservation, terminal decline, direct purchase, proposal acceptance, and operational delivery control while retaining `get_products`, `create_media_buy`, and `update_media_buy` as compatibility facades throughout 3.x. New purchase and control inputs never accept inline creatives; commercial amendments and negotiated cancellations fork an accepted proposal, while operational controls remain revision-checked. Compact purchase snapshots preserve resolved package flight, billing-measurement, performance, and reporting terms. A shared opportunity reference connects planning-cycle context through purchase without duplicating proposal version identity. Canonical inputs use stable brand keys, catalog references, and compact account and optimization types so brand assets, legacy named formats, creative provenance, and compliance payloads do not transitively enter the clean tools. Publish machine-readable SDK fallback grades and task-result schema resolution, plus MCP production, media-buy, and creative catalogs that remove presentation annotations without changing validation semantics. The role catalogs select active 3.2 seller-hosted operations and publish client-side input-only prompt views while retaining output and terminal task-result schemas in their parent validation catalogs. Preserve the AdCP 3.1 inventory-reservation contract in the compact lifecycle: requested and revised proposals remain immutable drafts until `refine_proposals` finalizes them, and a finalized `committed` snapshot guarantees inventory is held until `expires_at`. Add `countries` and `property_list` product-attribute filters, and publish compact task-specific async envelopes for consultative proposal planning and re-underwriting. -Let compact BrandKeys qualify commercial advertiser identity with canonical `countries[]` without turning identity into delivery targeting. Extend buyer-declared natural accounts with an operator-owned unit (`id` plus mutable display `name`), optional immutable account currency, and sandbox identity. These fields round-trip through `sync_accounts` and `list_accounts`; the operator unit remains explicitly distinct from the seller/storefront `account_id`. Require sellers to advertise fixed versus per-media-buy account currency support, and define only the BrandKey projection of a compatibility BrandRef as account identity. +Let compact BrandKeys qualify commercial advertiser identity with canonical `countries[]` without turning identity into delivery targeting. Extend buyer-declared natural accounts with an operator-owned unit (`id` plus mutable display `name`), optional immutable account currency, and sandbox identity. These fields round-trip through `sync_accounts` and `list_accounts`; the operator unit remains explicitly distinct from the seller/storefront `account_id`. Require sellers implementing 3.2 advertiser-account provisioning to advertise fixed versus per-media-buy account currency support while keeping the additive field optional on the shared 3.x response schema for 3.1 compatibility, and define only the BrandKey projection of a compatibility BrandRef as account identity. diff --git a/docs/accounts/tasks/sync_accounts.mdx b/docs/accounts/tasks/sync_accounts.mdx index 9eaaf7f75b..a9aa8637d0 100644 --- a/docs/accounts/tasks/sync_accounts.mdx +++ b/docs/accounts/tasks/sync_accounts.mdx @@ -98,7 +98,7 @@ asyncio.run(main()) | `brand` | object | Yes | Brand reference identifying the advertiser. Only its BrandKey fields — `domain`, optional `brand_id`, and the canonicalized ISO 3166-1 alpha-2 `countries[]` set — participate in account identity. Countries qualify commercial identity; they do not target delivery. The existing 3.x task still accepts broader BrandRef fields for compatibility, but mutable/per-call fields such as `industries`, `data_subject_contestation`, and `brand_kit_override` never affect lookup or upsert identity. New producers should send only BrandKey fields. | | `operator` | string | Yes | Domain of the entity operating on the brand's behalf (e.g. `pinnacle-media.com`). When the brand operates directly, set to the brand's domain. Verified against the brand's `authorized_operators` in brand.json. | | `operator_unit` | object | No | Operator-owned business unit, agency seat, or buying-platform account. `id` is the stable operator-assigned identifier and participates in the natural key; optional `name` is mutable display metadata and does not. This is not the seller's `account_id`. | -| `currency` | string | No | Immutable ISO 4217 transaction currency when the seller's advertiser object is currency-bound. Check `account.supported_account_currency_modes`: include `currency` for `fixed`; omit it for `per_media_buy`. A supplied currency participates in the natural key and every media buy on the account must use it. | +| `currency` | string | No | Immutable ISO 4217 transaction currency when the seller's advertiser object is currency-bound. Check `account.supported_account_currency_modes`: include `currency` for `fixed`; omit it for `per_media_buy`. If an older 3.x seller omits that capability, its currency model is not discoverable and must be established out of band. A supplied currency participates in the natural key and every media buy on the account must use it. | | `billing` | string | Yes | Who the seller invoices for this buyer–storefront account relationship: `operator`, `agent`, or `advertiser`. Check [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities) for `supported_billing`. The seller must accept the requested invoiced party exactly or reject the request. This field does not choose a payment rail, clearing intermediary, or per-media-buy settlement route; AdCP payment and settlement remain out of protocol. Sellers MAY additionally reject a seller-wide supported value when the calling buyer agent's commercial relationship does not permit it. See [`BILLING_NOT_SUPPORTED`](/docs/building/verification/compliance-catalog#error-code-billing-not-supported), [`BILLING_NOT_PERMITTED_FOR_AGENT`](/docs/building/verification/compliance-catalog#error-code-billing-not-permitted-for-agent), [Buyer-agent identity](/docs/building/by-layer/L2/accounts-and-agents#buyer-agent-identity), and [Billing and Account Setup](/docs/building/by-layer/L3/error-handling#billing-and-account-setup). | | `billing_entity` | object | No | Structured business entity details for the party responsible for payment. Contains `legal_name` (required), plus optional `vat_id`, `tax_id`, `registration_number`, `address`, `contacts`, and `bank`. Bank details are write-only — included in requests but never echoed in responses. See [billing entity and invoice recipient](/docs/building/by-layer/L2/accounts-and-agents#billing-entity-and-invoice-recipient). | | `payment_terms` | string | No | Payment terms for this account: `net_15`, `net_30`, `net_45`, `net_60`, `net_90`, or `prepay`. The seller must either accept these terms or reject the account — terms are never silently remapped. When omitted, the seller applies its default terms. | diff --git a/docs/building/by-layer/L2/accounts-and-agents.mdx b/docs/building/by-layer/L2/accounts-and-agents.mdx index 1fe7d3cef6..92572cf042 100644 --- a/docs/building/by-layer/L2/accounts-and-agents.mdx +++ b/docs/building/by-layer/L2/accounts-and-agents.mdx @@ -41,7 +41,7 @@ This is an account-level invoiced-party choice. It does not select a payment rai **2. Which account currency models do you support?** (`supported_account_currency_modes`) -AdCP 3.2 sellers advertise one or both values: +Sellers implementing AdCP 3.2 advertiser-account provisioning advertise one or both values. The field remains optional in the shared 3.x wire schema so existing 3.0 and 3.1 responses continue to validate; absence means the older seller's currency model is not discoverable and does not imply support for either mode. | Mode | Provisioning behavior | |------|------------------------| diff --git a/docs/protocol/capabilities-explorer.mdx b/docs/protocol/capabilities-explorer.mdx index 72a303db36..fbfa46890d 100644 --- a/docs/protocol/capabilities-explorer.mdx +++ b/docs/protocol/capabilities-explorer.mdx @@ -56,7 +56,7 @@ How accounts are negotiated; whether one is required before product discovery; w - **`authorization_endpoint`** — OAuth/auth URL for account negotiation. - **`require_operator_auth`** — declares whether operator-level authentication is required. - **`supported_billing`** — invoiced parties the seller supports (`operator`, `agent`, `advertiser`). -- **`supported_account_currency_modes`** — whether advertiser accounts use an immutable `fixed` currency, `per_media_buy` currency, or both. +- **`supported_account_currency_modes`** — whether advertiser accounts use an immutable `fixed` currency, `per_media_buy` currency, or both. Required for 3.2 advertiser-account provisioning; older 3.x sellers may omit it, which means the mode is not discoverable. - **`account_financials`** — what financial data the seller exposes (credit limits, current balance, etc.). - **`sandbox`** — sandbox-account capabilities. diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index a98e1ccd15..ad220d25b1 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -344,7 +344,7 @@ Account and authentication capabilities. All sellers should declare this section | Field | Type | Description | |-------|------|-------------| | `supported_billing` | string[] | **Required.** Billing models this seller supports: `operator`, `agent`, or `advertiser`. When the buyer calls `sync_accounts`, every entry must select one of these values. | -| `supported_account_currency_modes` | string[] | **Required in 3.2.** Advertiser-account currency models this seller supports: `fixed`, `per_media_buy`, or both. A buyer includes `currency` in `sync_accounts` for `fixed` and omits it for `per_media_buy`. When both are advertised, presence or absence selects the model. | +| `supported_account_currency_modes` | string[] | **Required for 3.2 advertiser-account provisioning; additive/optional on the shared 3.x wire schema for 3.1 compatibility.** Models this seller supports: `fixed`, `per_media_buy`, or both. A buyer includes `currency` in `sync_accounts` for `fixed` and omits it for `per_media_buy`. When both are advertised, presence or absence selects the model. Absence means an older seller has not exposed currency-mode discovery; it does not imply support for either mode. | | `require_operator_auth` | boolean | Default: `false`. Declares who must authenticate; it does not by itself declare whether OAuth is used, whether `list_accounts` is exposed, or which `sync_accounts` modes are supported. When `true`, each operator authenticates independently and account-scoped calls use seller/storefront-assigned `account_id` values. When `false`, the agent is trusted and calls use the advertiser natural key: `brand` + `operator` + optional `operator_unit`, fixed `currency`, and `sandbox`. `operator_unit.id` belongs to the operator and is not the seller's `account_id`. Buyer-declared sellers normally provision through `sync_accounts`, MUST continue accepting the natural key, and SHOULD expose `list_accounts` so all key fields can be recovered after a cold start. | | `authorization_endpoint` | string | OAuth URL for operator authentication. Present when the seller supports OAuth for operator authentication. Relevant when `require_operator_auth: true`; if absent, operators obtain credentials out-of-band (seller portal, API key). | | `required_for_products` | boolean | Default: `false`. When `true`, the buyer must establish an account before calling [`get_products`](/docs/media-buy/task-reference/get_products). When `false`, the buyer can browse products without an account — useful for price comparison and discovery before committing to a seller. | diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index 8e22c11907..66419e0533 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -657,7 +657,7 @@ }, "supported_account_currency_modes": { "type": "array", - "description": "Required in AdCP 3.2. Declares whether advertiser accounts are bound to one immutable currency (`fixed`), select currency independently per proposal or media buy (`per_media_buy`), or support both models. When only `fixed` is advertised, buyer-declared provisioning entries MUST include `currency`. When only `per_media_buy` is advertised, they MUST omit it. When both are advertised, presence of `currency` selects a fixed-currency account and omission selects per-media-buy currency.", + "description": "Required for sellers implementing AdCP 3.2 advertiser-account provisioning, but optional in this shared 3.x response schema so existing 3.0 and 3.1 capability responses remain valid. Declares whether advertiser accounts are bound to one immutable currency (`fixed`), select currency independently per proposal or media buy (`per_media_buy`), or support both models. When only `fixed` is advertised, buyer-declared provisioning entries MUST include `currency`. When only `per_media_buy` is advertised, they MUST omit it. When both are advertised, presence of `currency` selects a fixed-currency account and omission selects per-media-buy currency. Buyers MUST treat absence as an older seller whose currency mode is not discoverable, not as support for either mode.", "items": { "$ref": "/schemas/enums/account-currency-mode.json" }, @@ -763,7 +763,7 @@ "default": false } }, - "required": ["supported_billing", "supported_account_currency_modes"] + "required": ["supported_billing"] }, "media_buy": { "type": "object", diff --git a/tests/composed-schema-validation.test.cjs b/tests/composed-schema-validation.test.cjs index 7e45dbae8e..4dd6ef55dc 100644 --- a/tests/composed-schema-validation.test.cjs +++ b/tests/composed-schema-validation.test.cjs @@ -1671,13 +1671,18 @@ async function runTests() { } }; - await testSchemaRejection( + await testSchemaValidation( '/schemas/protocol/get-adcp-capabilities-response.json', { ...capabilitiesBase, + adcp: { + ...capabilitiesBase.adcp, + supported_versions: ['3.1'], + idempotency: { supported: false } + }, account: { supported_billing: ['operator', 'agent'] } }, - 'AdCP 3.2 account capabilities require currency-mode discovery' + 'AdCP 3.1 capability responses may omit additive currency-mode discovery' ); await testSchemaRejection(