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 c89d74a1f0..e74f56f037 100644 --- a/.changeset/secure-get-products-idempotency.md +++ b/.changeset/secure-get-products-idempotency.md @@ -5,3 +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 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/overview.mdx b/docs/accounts/overview.mdx index c3d49a0db2..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-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. 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`). 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 @@ -289,14 +292,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.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 buyer-declared seller MAY omit `sync_accounts` only when it lazily provisions from the 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/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/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 8d32af2586..a9aa8637d0 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 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,15 +95,36 @@ 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. 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. | -| `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). | +| `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`. 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. | | `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_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. + +```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 @@ -121,8 +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`. | +| `brand` | Echoed from request. Object with `domain`, optional `brand_id`, and optional `countries[]`. | | `operator` | Echoed from request. | +| `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 ba48f5071a..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` 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 `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 ea0a84e9a8..92572cf042 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 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`), 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 `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`) 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/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`. @@ -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`) + +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 | +|------|------------------------| +| `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"] } } ``` @@ -385,7 +403,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 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,8 +411,10 @@ 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`/`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_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. | @@ -427,7 +447,9 @@ 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_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. @@ -457,7 +479,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_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/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/media-buy/task-reference/request_proposals.mdx b/docs/media-buy/task-reference/request_proposals.mdx index 00e7d6149e..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` 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 `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) @@ -15,7 +15,18 @@ 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", + "countries": ["DE", "NL"] + }, + "operator": "pinnacle-media.example", + "operator_unit": { + "id": "seat_emea_01", + "name": "EMEA" + }, + "currency": "EUR" + }, "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..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 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/capabilities-explorer.mdx b/docs/protocol/capabilities-explorer.mdx index b647a24956..fbfa46890d 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. 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 2feacf522a..ad220d25b1 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -344,7 +344,8 @@ 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`. | +| `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. | | `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. | @@ -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", @@ -376,7 +378,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. @@ -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/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). 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/account-handlers.ts b/server/src/training-agent/account-handlers.ts index 792ffa7482..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,8 +39,10 @@ interface SyncAccountsInput extends ToolArgs { interface SyncAccountInput { account?: AccountRef; - brand?: { domain: string; brand_id?: string; name?: string }; + brand?: { domain: string; brand_id?: string; countries?: string[]; name?: string }; operator?: string; + operator_unit?: OperatorUnit; + currency?: string; billing?: 'operator' | 'agent' | 'advertiser'; billing_entity?: Record; payment_terms?: string; @@ -49,8 +52,10 @@ interface SyncAccountInput { interface AccountState { accountId: string; - brand: { domain: string; brand_id?: string; name?: string }; + brand: { domain: string; brand_id?: string; countries?: string[]; name?: string }; operator: string; + operatorUnit?: OperatorUnit; + currency?: string; billing: string; billingEntity?: Record; paymentTerms: string; @@ -131,9 +136,24 @@ function getAccountMap(sessionKey: string, principal?: string): Map, ref: AccountRef): AccountState | undefined { @@ -144,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)); + return accounts.get(accountKey( + ref.brand, + ref.operator, + ref.operator_unit, + ref.currency, + ref.sandbox === true, + )); } return undefined; } @@ -180,8 +206,11 @@ export function sandboxAccountRefForId( brand: { domain: account.brand.domain.toLowerCase(), ...(account.brand.brand_id && { brand_id: account.brand.brand_id }), + ...(account.brand.countries && { countries: [...account.brand.countries] }), }, operator: account.operator.toLowerCase(), + ...(account.operatorUnit && { operator_unit: { ...account.operatorUnit } }), + ...(account.currency && { currency: account.currency }), sandbox: true, }; } @@ -215,6 +244,8 @@ function accountStateFromWire(wire: AccountWireShape, now: string): AccountState accountId: wire.account_id, brand: wire.brand, operator: wire.operator, + operatorUnit: wire.operator_unit, + currency: wire.currency, billing: wire.billing, paymentTerms: wire.payment_terms ?? 'net_30', status: wire.status, @@ -243,10 +274,12 @@ 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/countries // only — `name` is not in the schema and additionalProperties is false. - brand: { domain: string; brand_id?: string }; + brand: { domain: string; brand_id?: string; countries?: string[] }; operator: string; + operator_unit?: OperatorUnit; + currency?: string; billing: string; account_scope: string; status: string; @@ -260,20 +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 } = { 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.countries !== undefined) wireBrand.countries = [...account.brand.countries]; const wire: AccountWireShape = { account_id: account.accountId, name: displayName, advertiser, brand: wireBrand, operator: account.operator, + ...(account.operatorUnit && { operator_unit: { ...account.operatorUnit } }), + ...(account.currency && { currency: account.currency }), billing: account.billing, account_scope: account.accountScope, status: account.status, @@ -320,7 +356,14 @@ 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_unit && { operator_unit: input.operator_unit }), + ...(input.currency && { currency: input.currency }), + }), action: 'failed', status: 'rejected', errors: [{ code: 'VALIDATION_ERROR', field, message }], @@ -515,7 +558,23 @@ 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_unit, + accountRef!.currency, + accountRef!.sandbox === true, + ) !== 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; const subscriberKey = `${account.accountId}\u001F${config.subscriberId}\u001F${notificationType}`; @@ -549,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, @@ -586,8 +662,12 @@ 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; countries?: string[]; name?: string } | undefined; const operator = fixture.operator; + 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' }; } @@ -600,19 +680,28 @@ 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; countries?: string[] }, + operator, + operatorUnit, + currency, + 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; countries?: string[]; name?: string }, operator, + operatorUnit, + currency, 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 +784,27 @@ export const ACCOUNT_REF_SCHEMA = { properties: { domain: { type: 'string' }, brand_id: { type: 'string' }, + countries: { + type: 'array', + items: { type: 'string', pattern: '^[A-Z]{2}$' }, + minItems: 1, + uniqueItems: true, + }, }, required: ['domain'], additionalProperties: false, }, operator: { type: 'string' }, + 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'], @@ -759,12 +864,28 @@ export const ACCOUNT_TOOLS = [ properties: { domain: { type: 'string' }, brand_id: { type: 'string' }, + 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_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'] }, @@ -802,6 +923,8 @@ export const ACCOUNT_TOOLS = [ allOf: [ { not: { required: ['brand'] } }, { not: { required: ['operator'] } }, + { not: { required: ['operator_unit'] } }, + { not: { required: ['currency'] } }, { not: { required: ['billing'] } }, ], }, @@ -911,6 +1034,8 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { const mixedProvisioningFields = [ input.brand !== undefined && 'brand', input.operator !== undefined && 'operator', + input.operator_unit !== undefined && 'operator_unit', + input.currency !== undefined && 'currency', input.billing !== undefined && 'billing', input.sandbox !== undefined && 'sandbox', ].filter(Boolean); @@ -944,7 +1069,13 @@ 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.operatorUnit, + existing.currency, + existing.sandbox, + ), existing); } if (input.payment_terms && !SUPPORTED_PAYMENT_TERMS.includes(input.payment_terms)) { @@ -980,6 +1111,8 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { account: input.account, brand: existing.brand, operator: existing.operator, + ...(existing.operatorUnit && { operator_unit: existing.operatorUnit }), + ...(existing.currency && { currency: existing.currency }), action: 'updated', status: existing.status, billing: existing.billing, @@ -1124,7 +1257,13 @@ 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_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)}`; @@ -1143,6 +1282,8 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { results.push({ brand: input.brand, operator: input.operator, + ...(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, @@ -1168,6 +1309,8 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { accountId, brand: input.brand, operator: input.operator, + operatorUnit: input.operator_unit, + currency: input.currency, billing: input.billing!, billingEntity: input.billing_entity, paymentTerms: input.payment_terms || 'net_30', @@ -1175,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 @@ -1192,7 +1335,11 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) { account_id: accountId, brand: input.brand, operator: input.operator, - 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, @@ -1248,7 +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 ( + JSON.stringify([...(account.brand.countries ?? [])].sort()) + !== JSON.stringify([...(ref.brand.countries ?? [])].sort()) + ) return false; if (account.operator !== ref.operator) 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 5cb44fcee6..1f872dc803 100644 --- a/server/src/training-agent/account-scope.ts +++ b/server/src/training-agent/account-scope.ts @@ -1,17 +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', '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 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_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 }; + brand: { domain: string; brand_id?: string; countries?: string[] }; operator: string; + operator_unit?: OperatorUnit; + currency?: string; sandbox: boolean; }; @@ -48,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. */ @@ -83,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'); @@ -93,6 +111,53 @@ export function canonicalizeAccountRef(value: unknown): CanonicalAccountRef { } brandId = value.brand.brand_id; } + 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.'); + } + countries = [...value.brand.countries].sort(); + } + 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 ( + 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_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.'); + } + currency = value.currency; + } if (value.sandbox !== undefined && typeof value.sandbox !== 'boolean') { invalid('account.sandbox must be a boolean when provided.'); } @@ -102,8 +167,11 @@ export function canonicalizeAccountRef(value: unknown): CanonicalAccountRef { brand: { domain, ...(brandId !== undefined && { brand_id: brandId }), + ...(countries !== undefined && { countries }), }, operator, + ...(operatorUnit !== undefined && { operator_unit: operatorUnit }), + ...(currency !== undefined && { currency }), sandbox: value.sandbox ?? false, }; } @@ -112,11 +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}`; - return [ - 'n', - account.brand.domain, - account.brand.brand_id ?? '-', - account.operator, - account.sandbox ? '1' : '0', - ].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 df7d059195..5db46be930 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, @@ -2375,11 +2377,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 }), + ...(Array.isArray(internal.__brand_countries) && { countries: internal.__brand_countries }), })!); } } @@ -3957,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: { @@ -4208,7 +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 : ''; - return `b:${brand.domain.toLowerCase()}#${brandId}`; + 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 { @@ -4398,7 +4390,7 @@ function proposalTermsDigest(commercialTerms: Record): string { function buildCanonicalCommercialTerms( proposal: Proposal, products: Map, - brand: { domain: string; brand_id?: string }, + brand: { domain: string; brand_id?: string; countries?: string[] }, ): Record { const internal = proposal as unknown as Record; if (isRecord(internal.__canonical_commercial_terms)) { @@ -4449,7 +4441,7 @@ function buildCanonicalCommercialTerms( function withCanonicalProposalEnvelope( proposal: Proposal, products: Map, - brand: { domain: string; brand_id?: string }, + brand: { domain: string; brand_id?: string; countries?: string[] }, options: { rebuild?: boolean } = {}, ): Proposal { const internal = { ...proposal } as unknown as Record; @@ -4477,6 +4469,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 }), + ...(Array.isArray(requestBrand?.countries) + && { countries: [...requestBrand.countries].filter(country => typeof country === 'string').sort() }), }, + ...(typeof requestAccount?.operator === 'string' && { operator: requestAccount.operator.toLowerCase() }), + ...(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) => { const digest = createHash('sha256') @@ -6317,7 +6322,10 @@ 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 }), + ...(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' && { __opportunity_id: requestOpportunity.opportunity_id }), } as unknown as Proposal; @@ -6330,6 +6338,8 @@ async function handleGetProductsUnlocked( ? requestBrand.domain.toLowerCase() : 'advertiser.example', ...(typeof requestBrand?.brand_id === 'string' && { brand_id: requestBrand.brand_id }), + ...(Array.isArray(requestBrand?.countries) + && { countries: [...requestBrand.countries].filter(country => typeof country === 'string').sort() }), }, ); }); @@ -6400,6 +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 brandCountries = (proposal as unknown as Record).__brand_countries; let successor = refinement?.action === 'finalize' ? executableProposalSnapshot( revision, @@ -6411,6 +6422,7 @@ async function handleGetProductsUnlocked( { domain: typeof brandDomain === 'string' ? brandDomain : 'advertiser.example', ...(typeof brandId === 'string' && { brand_id: brandId }), + ...(Array.isArray(brandCountries) && { countries: brandCountries }), }, { rebuild: true }, ); @@ -7507,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 @@ -7580,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) { @@ -7887,10 +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 }; + 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 boundBrandCountries = internal.__brand_countries; const hasCompactOwnerBinding = typeof boundAccountId === 'string' || typeof boundBrandDomain === 'string' || typeof boundBrandId === 'string'; @@ -7901,6 +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) + && JSON.stringify([...(requestBrand.countries ?? [])].sort()) + === JSON.stringify(Array.isArray(boundBrandCountries) ? [...boundBrandCountries].sort() : []) ); if (hasCompactOwnerBinding && (!accountMatches || !brandMatches)) proposal = undefined; } @@ -7921,6 +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' + || Array.isArray(internalProposal.__brand_countries) || typeof internalProposal.__account_id === 'string'; if (internalProposal.__declined === true) { return { @@ -8146,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}`; @@ -8321,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 @@ -10110,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/src/training-agent/types.ts b/server/src/training-agent/types.ts index c57ee833fb..941c94f5c2 100644 --- a/server/src/training-agent/types.ts +++ b/server/src/training-agent/types.ts @@ -416,13 +416,21 @@ 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; countries?: string[] }; operator?: string; + operator_unit?: OperatorUnit; + currency?: string; sandbox?: boolean; } +export interface OperatorUnit { + id: string; + name?: string; +} + export interface BrandRef { domain: string; + countries?: string[]; name?: string; } 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)); diff --git a/server/tests/unit/account-handlers.test.ts b/server/tests/unit/account-handlers.test.ts index c6b7b1b9f6..2cdde1bd3c 100644 --- a/server/tests/unit/account-handlers.test.ts +++ b/server/tests/unit/account-handlers.test.ts @@ -125,6 +125,97 @@ describe('sync_accounts', () => { expect(acct.setup).toBeUndefined(); }); + 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', countries: ['NL'] }, + operator: 'nova-athletics.example', + operator_unit: { id: '234284238', name: 'Nova EMEA' }, + currency: 'EUR', + billing: 'operator', + sandbox: true, + }, + { + brand: { domain: 'nova-athletics.example', countries: ['BE'] }, + operator: 'nova-athletics.example', + operator_unit: { id: '234284238', name: 'Nova EMEA' }, + currency: 'EUR', + 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', countries: ['NL'] }, + operator: 'nova-athletics.example', + 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('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: [{ @@ -688,7 +779,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, @@ -712,7 +803,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, }, @@ -915,7 +1006,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 +1026,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/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 ff63134f32..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,8 +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, countries: ['NL'] } }), + accountScopeFromRef({ ...base, operator_unit: { id: '234284238', name: 'Nova EMEA' } }), + accountScopeFromRef({ ...base, currency: 'EUR' }), ]); - expect(scopes.size).toBe(4); + expect(scopes.size).toBe(7); + }); + + it('represents a country-scoped brand under an operator unit and fixed currency', () => { + const account = { + brand: { domain: 'Nova-Athletics.Example', countries: ['NL', 'BE'] }, + operator: 'Nova-Athletics.Example', + operator_unit: { id: '234284238', name: 'Nova EMEA' }, + currency: 'EUR', + }; + expect(canonicalizeAccountRef(account)).toEqual({ + kind: 'natural', + brand: { domain: 'nova-athletics.example', countries: ['BE', 'NL'] }, + operator: 'nova-athletics.example', + operator_unit: { id: '234284238', name: 'Nova EMEA' }, + currency: 'EUR', + sandbox: false, + }); + expect(accountScopeFromRef(account)) + .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([ @@ -49,6 +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', 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); @@ -58,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({ @@ -96,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); }); @@ -132,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..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(); @@ -3233,6 +3234,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/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/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 23815c1727..21da0f0d0a 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 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) 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}$" @@ -31,16 +31,25 @@ }, "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", "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_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", + "pattern": "^[A-Z]{3}$", + "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", @@ -123,6 +132,20 @@ ] } }, + { + "not": { + "required": [ + "operator_unit" + ] + } + }, + { + "not": { + "required": [ + "currency" + ] + } + }, { "not": { "required": [ @@ -168,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 fd9f67f802..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) when brand + operator (+ sandbox) is the durable protocol key 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. 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,6 +28,15 @@ "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$", "x-entity": "operator" }, + "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", + "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", "description": "When true, references the sandbox account for this brand/operator pair. Defaults to false (production account).", @@ -42,6 +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", "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 e881afd19f..4f62718d32 100644 --- a/static/schemas/source/core/account.json +++ b/static/schemas/source/core/account.json @@ -36,6 +36,15 @@ "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$", "x-entity": "operator" }, + "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", + "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", "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..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. 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": { @@ -13,6 +13,16 @@ "brand_id": { "$ref": "/schemas/core/brand-id.json", "description": "Brand within a house-of-brands manifest. Omit for a single-brand domain." + }, + "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 94d4e4ca30..e13c3aec71 100644 --- a/static/schemas/source/core/brand-ref.json +++ b/static/schemas/source/core/brand-ref.json @@ -14,6 +14,16 @@ "$ref": "/schemas/core/brand-id.json", "description": "Brand identifier within the house portfolio. Optional for single-brand domains." }, + "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", "items": { "type": "string" }, @@ -76,6 +86,10 @@ }, { "domain": "acme-corp.com" + }, + { + "domain": "nova-athletics.example", + "countries": ["NL"] } ] } diff --git a/static/schemas/source/core/canonical-account-ref.json b/static/schemas/source/core/canonical-account-ref.json index dfefc08b12..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.", + "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,6 +26,15 @@ "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$", "x-entity": "operator" }, + "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", + "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", "default": false @@ -34,5 +43,14 @@ "required": ["brand", "operator"], "additionalProperties": false } + ], + "examples": [ + { "account_id": "acc_nova_nl" }, + { + "brand": { "domain": "nova-athletics.example", "countries": ["NL"] }, + "operator": "nova-athletics.example", + "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/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/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", diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index 2ea6f40d34..66419e0533 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -639,7 +639,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": { @@ -655,6 +655,15 @@ }, "minItems": 1 }, + "supported_account_currency_modes": { + "type": "array", + "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" + }, + "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.", diff --git a/tests/composed-schema-validation.test.cjs b/tests/composed-schema-validation.test.cjs index 0f9b747f8a..4dd6ef55dc 100644 --- a/tests/composed-schema-validation.test.cjs +++ b/tests/composed-schema-validation.test.cjs @@ -1665,9 +1665,38 @@ 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 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.1 capability responses may omit additive 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 +1920,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 +1940,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 +1954,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 +1972,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'] @@ -3067,13 +3100,43 @@ async function runTests() { { idempotency_key: 'request-proposals-natural-account-0001', account: { - brand: { domain: 'acmeoutdoor.example' }, + brand: { domain: 'acmeoutdoor.example', countries: ['NL'] }, operator: 'buyer.example', + operator_unit: { id: '234284238', name: 'Acme EMEA' }, + currency: 'EUR', sandbox: true }, brief: 'Reach streaming audio listeners in Rome' }, - 'request_proposals accepts a natural-key account as the single brand source' + '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-account-key-0001', + account: { + brand: { domain: 'acmeoutdoor.example', countries: ['nl'] }, + operator: 'buyer.example', + operator_unit: { id: '234284238', name: 'Acme EMEA' }, + currency: 'eur' + }, + brief: 'Reach streaming audio listeners in Rome' + }, + 'request_proposals rejects non-canonical country and currency identifiers' + ); + await testSchemaValidation( + '/schemas/account/sync-accounts-request.json', + { + idempotency_key: 'sync-accounts-operator-unit-0001', + accounts: [{ + brand: { domain: 'nova-athletics.example', countries: ['NL'] }, + operator: 'nova-athletics.example', + operator_unit: { id: '234284238', name: 'Nova EMEA' }, + currency: 'EUR', + billing: 'operator' + }] + }, + 'sync_accounts provisions the same advertiser natural key used by compact tools' ); await testSchemaRejection( '/schemas/media-buy/request-proposals-request.json', 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/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()); +}); 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: {