diff --git a/.changeset/add-dooh-interoperability-contracts.md b/.changeset/add-dooh-interoperability-contracts.md
new file mode 100644
index 0000000000..92c9b26a51
--- /dev/null
+++ b/.changeset/add-dooh-interoperability-contracts.md
@@ -0,0 +1,5 @@
+---
+"adcontextprotocol": minor
+---
+
+Add the `sales-dooh` specialism and a digital out-of-home, non-guaranteed compliance storyboard using the existing channel, product, placement, canonical-format, play, and DOOH-metric contracts. Exercise typed DOOH placement identifiers, loop/slot timing, screen resolution, and motion facts while keeping canonical `format_options` authoritative for creative acceptance. Extend deterministic delivery simulation to prove `plays` and `dooh_metrics`, document optional vendor-defined attention without making it part of the core DOOH claim, require every 3.2 product-list path to expose resolved canonical formats without a separate source-mode capability, set a 128 KiB MCP interoperability target with `tools/list` pagination guidance, prevent success-payload duplication across MCP text and `structuredContent`, and clarify that sandbox behavior is selected by the resolved account rather than switched on per media-buy request.
diff --git a/.changeset/dooh-placement-attributes.md b/.changeset/dooh-placement-attributes.md
new file mode 100644
index 0000000000..bd8fc01f41
--- /dev/null
+++ b/.changeset/dooh-placement-attributes.md
@@ -0,0 +1,5 @@
+---
+"adcontextprotocol": minor
+---
+
+Add DOOH structured selling-unit fields to placements: `dooh_placement_attributes` (slot_duration_seconds, loop_duration_seconds, screen_resolution, motion) and `identifiers[]` on both placement.json and placement-definition.json. Define deterministic publisher/product inheritance, post-merge slot-to-loop validation, versioned OpenOOH identifiers, and canonical-format authority. Add the `dooh-motion-type` enum and supersede pricing-layer loop_duration_seconds in flat-rate-option.json.
diff --git a/docs/building/by-layer/L0/mcp-response-extraction.mdx b/docs/building/by-layer/L0/mcp-response-extraction.mdx
index cf865b8d20..f914725602 100644
--- a/docs/building/by-layer/L0/mcp-response-extraction.mdx
+++ b/docs/building/by-layer/L0/mcp-response-extraction.mdx
@@ -86,6 +86,8 @@ MCP 2025-03-26 introduced `structuredContent` for typed tool results. AdCP serve
The `structuredContent` object IS the AdCP response — task-specific fields (`products`, `media_buy_id`, `status`, etc.) are at the top level, not nested.
+When `structuredContent` carries the authoritative success payload, `content` MAY contain a terse human-readable summary. Producers SHOULD NOT serialize the complete AdCP payload again into `content[].text`; doing so doubles the wire bytes and model-context cost without adding information. A server supporting an older MCP client that cannot read `structuredContent` MAY instead put the JSON payload in text as the fallback form below and omit `structuredContent`. It should not emit both full copies.
+
### Text Fallback
Older MCP servers (pre-2025-03-26) serialize the response as JSON in `content[].text`:
@@ -127,7 +129,7 @@ All data in `structuredContent` and `content[].text` is seller-controlled. The s
### Size Limits
-Clients SHOULD enforce a maximum payload size before processing. A recommended limit is 1MB for `structuredContent`. For text fallback, apply the limit before `JSON.parse` to prevent memory exhaustion from oversized payloads.
+For pageable reads and other read-only tasks that support field projection, producers SHOULD target at most 128 KiB per serialized MCP response; see [Managing response size](/docs/building/concepts/managing-response-size#interoperability-size-target). Clients SHOULD independently enforce a streamed limit on the complete serialized MCP message before buffering or JSON decoding. The recommended client safety limit is 1 MiB. For text fallback, also check the `content[].text` size before the inner `JSON.parse`. The larger safety limit is not a producer target: it bounds hostile or buggy input after normal interoperability guidance has failed.
### Prototype Pollution
@@ -145,6 +147,7 @@ Client libraries that implement this spec MUST:
2. **Prefer `structuredContent`.** Only fall back to text parsing when `structuredContent` is absent.
3. **Validate parsed text.** Only accept non-array objects from `JSON.parse`. Reject arrays, strings, numbers, booleans, and null.
4. **Handle `adcp_error`-only `structuredContent`.** When `structuredContent` contains only an `adcp_error` key, return null — this is an error response that may be missing the `isError` flag.
+5. **Do not require duplicated success data.** Treat summary-only `content[].text` as normal whenever `structuredContent` is present; never compare it with, or expect it to reproduce, the structured payload.
## Test Vectors
diff --git a/docs/building/by-layer/L4/build-an-agent.mdx b/docs/building/by-layer/L4/build-an-agent.mdx
index e3cbca3b35..2e42570f71 100644
--- a/docs/building/by-layer/L4/build-an-agent.mdx
+++ b/docs/building/by-layer/L4/build-an-agent.mdx
@@ -99,7 +99,7 @@ Each agent declares its `supported_protocols` (domains) and `specialisms` on `ge
| Skill | Typical `supported_protocols` | Typical `specialisms` (pick one or combine) |
|---|---|---|
-| `build-seller-agent` | `["media_buy", "creative"]` | `sales-guaranteed`, `sales-non-guaranteed` |
+| `build-seller-agent` | `["media_buy", "creative"]` | `sales-guaranteed`, `sales-non-guaranteed`, `sales-dooh` |
| `build-generative-seller-agent` | `["media_buy", "creative"]` | `creative-generative` + `sales-non-guaranteed` |
| `build-retail-media-agent` | `["media_buy", "creative"]` | `sales-catalog-driven` |
| `build-signals-agent` | `["signals"]` | `signal-owned`, `signal-marketplace` |
@@ -108,6 +108,7 @@ Each agent declares its `supported_protocols` (domains) and `specialisms` on `ge
**Picking a sales specialism:** See [Choosing a sales specialism](/docs/building/verification/compliance-catalog#choosing-a-sales-specialism) in the Compliance Catalog for the full decision tree. Quick reference:
- **`sales-guaranteed`** — IO approval, fixed pricing. Set `media_buy.supports_proposals: true` if you support RFP/proposal flows; `false` (or omit) for direct-buy only.
- **`sales-non-guaranteed`** — auction / PMP.
+- **`sales-dooh`** — non-guaranteed digital out-of-home venue and screen inventory.
- **`sales-broadcast-tv`**, **`sales-catalog-driven`**, **`sales-social`** — channel-specific; see the decision tree.
You can claim more than one. See the [Compliance Catalog](/docs/building/verification/compliance-catalog) for the full taxonomy and per-specialism storyboards.
diff --git a/docs/building/concepts/managing-response-size.mdx b/docs/building/concepts/managing-response-size.mdx
index a20ac4db0a..f0c319ed1f 100644
--- a/docs/building/concepts/managing-response-size.mdx
+++ b/docs/building/concepts/managing-response-size.mdx
@@ -10,6 +10,16 @@ seller-planned proposals to a wholesale mirror containing detailed product,
signal, and placement metadata. This page covers the controls that keep those
responses right-sized—and the client-side projection that matters most.
+## Interoperability size target
+
+MCP hosts and gateways impose different undocumented response ceilings. For pageable reads and other read-only tasks that offer field projection, AdCP producers SHOULD keep each serialized MCP response at or below **128 KiB (131,072 bytes)**. This is an interoperability target, not permission for a client to allocate without bounds: clients SHOULD still enforce the streamed 1 MiB whole-message cap described in [MCP response extraction](/docs/building/by-layer/L0/mcp-response-extraction#size-limits).
+
+For those read paths, use the task's cursor, reduce the requested fields or breakdowns, or return a smaller schema-valid page with continuation metadata before the response exceeds 128 KiB. Do not invent a partial response for a task whose schema does not define one.
+
+Do not apply the 128 KiB target by turning a completed mutation into an error. If a producer can determine before execution that it cannot return the mutation's required result within a host limit, it MUST reject the request before side effects. Once side effects occur, it MUST return the schema-valid committed result or a task-defined asynchronous handoff; it must not report failure merely to satisfy the size target.
+
+`tools/list` is cursor-pageable at the MCP layer even though it is not an AdCP task. Servers SHOULD paginate the live tool catalog so each page remains under the same 128 KiB target. Clients that need the complete catalog MUST follow `nextCursor` until it is absent. Capability-selected tool registration and concise `x-tool-summary` descriptions reduce each page further; do not publish the full AdCP catalog, response schemas, or long reference prose in a session's `tools/list` result.
+
## Wire response ≠ model context
The single most important point: **the bytes on the wire are not what your model has to consume.**
diff --git a/docs/building/verification/aao-verified.mdx b/docs/building/verification/aao-verified.mdx
index 67b6df0503..91e13dc9d7 100644
--- a/docs/building/verification/aao-verified.mdx
+++ b/docs/building/verification/aao-verified.mdx
@@ -13,7 +13,7 @@ description: "The public trust mark for AdCP agents. Two qualifiers — Verified
It is two axes, not two tiers. The qualifiers answer different questions:
- **Verified (Spec)** — your AdCP protocol implementation matches the spec. Storyboards pass somewhere — could be a test deployment, could be local dev. Wire format, task shape, error semantics, state-machine transitions all check out. Attests *wire-format conformance*, not production tolerance.
-- **Verified (Sandbox)** — your **real production endpoint** correctly honors `account.sandbox: true`. AAO runs the full storyboard suite against your registered `agent_url` with sandbox-flagged traffic; your prod stack processes it with schema-valid responses, correct lifecycle transitions, proper error envelopes, and **no real-world side effects** (no real spend, no real persistence, no real platform calls). Attests *the production code path tolerates test traffic correctly*.
+- **Verified (Sandbox)** — your **real production endpoint** correctly resolves and isolates sandbox accounts. AgenticAdvertising.org runs the full storyboard suite against your registered `agent_url`, using natural-key references with `sandbox: true` or preverified sandbox `account_id` references; your prod stack processes them with schema-valid responses, correct lifecycle transitions, proper error envelopes, and **no real-world side effects** (no real spend, no real persistence, no real platform calls). Attests *the production code path tolerates test traffic correctly*.
An agent can earn either axis or both. A pure protocol wrapper around a stub ad server is honestly **Verified (Spec)** — that's what test agents and dev environments *are*. A real production seller whose prod URL handles sandbox traffic across the full storyboard suite earns **Verified (Spec + Sandbox)**, the strongest claim available.
@@ -22,12 +22,12 @@ The two axes are **orthogonal** — neither is a prerequisite for the other. A s
The badge surfaces whichever qualifiers are earned.
-**TL;DR for sellers.** Both qualifiers run the same storyboards through the same AAO compliance heartbeat. The difference is *where* the runner targets and *what* the seller's stack does with sandbox-flagged traffic:
+**TL;DR for sellers.** Both qualifiers run the same storyboards through the same AgenticAdvertising.org compliance heartbeat. The difference is *where* the runner targets and *what* the seller's stack does with sandbox-account traffic:
- **(Spec)** runs storyboards against a test deployment / local dev / a sandbox endpoint. The agent's prod surface is not exercised.
-- **(Sandbox)** runs storyboards against the seller's registered production `agent_url` with `account.sandbox: true` on every request. The seller's prod stack MUST honor the flag — return schema-valid responses, transition state correctly, surface errors properly, and have **zero real-world side effects** (no billing, no persistence beyond the sandbox account, no third-party platform calls).
+- **(Sandbox)** runs storyboards against the seller's registered production `agent_url` with sandbox-account references. Natural-key references repeat `sandbox: true`; account-ID references use a previously verified sandbox ID and cannot carry a sandbox flag. The seller's prod stack MUST return schema-valid responses, transition state correctly, surface errors properly, and have **zero real-world side effects** (no billing, no persistence beyond the sandbox account, no third-party platform calls).
-The seller-side sandbox gate is normative: every sandbox-flagged production request must resolve to a persisted sandbox account, not a live account wearing a `sandbox: true` claim. See [comply_test_controller](/docs/building/by-layer/L3/comply-test-controller) for the dev-side deterministic-testing affordance; Sandbox grading itself does not require or use the controller.
+The seller-side sandbox gate is normative: every sandbox-account reference must resolve to a persisted sandbox account, not a live account relabeled for the request. See [comply_test_controller](/docs/building/by-layer/L3/comply-test-controller) for the dev-side deterministic-testing affordance; Sandbox grading itself does not require or use the controller.
## What each axis certifies
@@ -47,14 +47,14 @@ The seller-side sandbox gate is normative: every sandbox-flagged production requ
| | |
|---|---|
-| **Tested against** | Your registered production `agent_url`, with `account.sandbox: true` on every request. |
+| **Tested against** | Your registered production `agent_url`, using natural-key references with `sandbox: true` or preverified sandbox `account_id` references. |
| **What it proves** | Your production code path correctly honors sandbox flagging — same storyboards as (Spec), but exercised against the real prod stack buyers actually hit. Schema-valid responses, correct lifecycle transitions, proper error envelopes, **zero real-world side effects**. |
-| **How** | Same storyboard suite as (Spec), driven against the seller's registered URL with sandbox-flagged traffic. No separate canonical-campaign infrastructure. |
+| **How** | Same storyboard suite as (Spec), driven against the seller's registered URL with sandbox-account traffic. No separate canonical-campaign infrastructure. |
| **Cadence** | Same ~1h heartbeat as (Spec) |
-| **Eligibility** | Same as (Spec), PLUS the seller's prod surface accepts `account.sandbox: true` requests and processes them without persisting real state, calling third-party platforms, or billing |
+| **Eligibility** | Same as (Spec), PLUS the seller's prod surface resolves sandbox accounts through its supported account model and processes them without persisting real state, calling third-party platforms, or billing |
| **Status** | **Foundation shipping** in [#4382](https://github.com/adcontextprotocol/adcp/pull/4382) (account.sandbox schema gate), [#4384](https://github.com/adcontextprotocol/adcp/pull/4384) (live-mode denial storyboard). Full grading framework following. |
-The (Sandbox) qualifier replaces the earlier draft's `Verified (Live)` framing. The change: instead of attesting "your real-money production code path delivers impressions correctly" (canonical campaigns running through your stack), (Sandbox) attests "your real production code path correctly handles sandbox-flagged traffic across the full storyboard suite." Both are real-prod-surface claims; the difference is what gets tested. (Sandbox) is universally achievable across specialisms with no new AAO operational infrastructure. See [#4379](https://github.com/adcontextprotocol/adcp/issues/4379) for the reframe verdict.
+The (Sandbox) qualifier replaces the earlier draft's `Verified (Live)` framing. The change: instead of attesting "your real-money production code path delivers impressions correctly" (canonical campaigns running through your stack), (Sandbox) attests "your real production code path correctly handles sandbox-account traffic across the full storyboard suite." Both are real-prod-surface claims; the difference is what gets tested. (Sandbox) is universally achievable across specialisms with no new AAO operational infrastructure. See [#4379](https://github.com/adcontextprotocol/adcp/issues/4379) for the reframe verdict.
**Re: [`comply_test_controller`](/docs/building/by-layer/L3/comply-test-controller)**: the controller is a **dev/staging-only** affordance for adopters' own integration testing. AAO's (Sandbox) grading does not require or use it. Sellers MAY implement controller endpoints in their dev environment to support deterministic local testing, but the production stack does not need to expose `comply_test_controller` to earn (Sandbox). The seller-side sandbox gate is what (Sandbox) attests — schema and lifecycle correctness under flagged traffic, on real prod. How the dev-time test surface itself is stood up — DB-backed `seed_*` for state-local sellers vs the SDK's `TestControllerBridge` for upstream-proxy sellers — is covered in [Test surfaces and the storyboard loop](/docs/building/verification/conformance#test-surfaces-and-the-storyboard-loop).
@@ -76,7 +76,7 @@ The earlier draft's rejection of "Tier 1 / Tier 2" remains correct: tiering the
## Coverage gaps are explicit
-Under the (Sandbox) framing, every applicable storyboard is attempted against the seller's production endpoint with sandbox-flagged traffic. There's no observability carve-out — universal storyboards (`signed_requests`, `pagination_integrity`, etc.) run as part of the standard suite. The registry keeps not-selected items separate from selected-but-skipped items: run-mode exclusions ("this was a sandbox-only run, so live-only probes were not selected"), declined optional capabilities ("the seller did not claim this feature"), and deterministic-test-surface gaps ("the production endpoint correctly omits [`comply_test_controller`](/docs/building/by-layer/L3/comply-test-controller)"). Those are different asks of the seller and MUST NOT be collapsed into one generic "skipped optional things" bucket. The (Sandbox) qualifier is a verification profile over that evidence: it defines which not-selected and skipped classes are acceptable for the Sandbox badge and which remain blockers. See [#4379](https://github.com/adcontextprotocol/adcp/issues/4379) for the framing decision that replaced the earlier (Live) observability model.
+Under the (Sandbox) framing, every applicable storyboard is attempted against the seller's production endpoint with sandbox-account traffic. There's no observability carve-out — universal storyboards (`signed_requests`, `pagination_integrity`, etc.) run as part of the standard suite. The registry keeps not-selected items separate from selected-but-skipped items: run-mode exclusions ("this was a sandbox-only run, so live-only probes were not selected"), declined optional capabilities ("the seller did not claim this feature"), and deterministic-test-surface gaps ("the production endpoint correctly omits [`comply_test_controller`](/docs/building/by-layer/L3/comply-test-controller)"). Those are different asks of the seller and MUST NOT be collapsed into one generic "skipped optional things" bucket. The (Sandbox) qualifier is a verification profile over that evidence: it defines which not-selected and skipped classes are acceptable for the Sandbox badge and which remain blockers. See [#4379](https://github.com/adcontextprotocol/adcp/issues/4379) for the framing decision that replaced the earlier (Live) observability model.
## Reading a badge
@@ -85,8 +85,8 @@ Badges render as a single shields.io-style image with the qualifiers in parens:
| Display | Meaning |
|---|---|
| `AAO Verified Sales Agent (Spec)` | Storyboards pass for declared media-buy specialisms against a test deployment / dev / sandbox-only endpoint. Wire format and protocol semantics are correct; production-stack sandbox tolerance is not yet attested. Common for test agents and pre-production rollouts. |
-| `AAO Verified Sales Agent (Spec + Sandbox)` | Both axes earned. The strongest claim. The agent's registered production URL handles the full storyboard suite under sandbox-flagged traffic with no real-world side effects. |
-| `AAO Verified Sales Agent (Sandbox)` | Storyboards pass against the seller's registered production endpoint under `account.sandbox: true`. The seller's prod stack correctly honors the sandbox gate. Common for production-only sellers without a separate test deployment. |
+| `AAO Verified Sales Agent (Spec + Sandbox)` | Both axes earned. The strongest claim. The agent's registered production URL handles the full storyboard suite under sandbox-account traffic with no real-world side effects. |
+| `AAO Verified Sales Agent (Sandbox)` | Storyboards pass against the seller's registered production endpoint using verified sandbox-account references. The seller's prod stack correctly honors the sandbox gate. Common for production-only sellers without a separate test deployment. |
| `AAO Verified — Not Verified` | No badge issued for this agent + role, or the badge has been revoked. |
The badge URL is stable per agent + role. As an agent earns or loses an axis, the SVG content updates without changing the URL — embedded badges automatically reflect the current state.
@@ -129,12 +129,12 @@ The compliance heartbeat picks it up automatically — no manual enrollment need
A seller earns (Sandbox) by:
1. Registering their **production `agent_url`** with AAO. This is the same registration that earns (Spec) — no separate "compliance account" or "test deployment" needed.
-2. Implementing the **sandbox-account gate** in their production stack: when a request arrives with `account.sandbox: true`, the seller verifies the targeted account is a sandbox account in the persisted record (not trusting the field), and processes the request with full schema/lifecycle correctness while producing **zero real-world side effects** — no real spend, no real ad-server orders, no third-party platform calls, no production persistence beyond the sandbox account's bounded state.
+2. Implementing the **sandbox-account gate** in their production stack: a natural key with `sandbox: true` resolves only the distinct sandbox account, while an `account_id` resolves only the pre-existing account whose stored mode has already been verified. The seller processes that account with full schema/lifecycle correctness while producing **zero real-world side effects** — no real spend, no real ad-server orders, no third-party platform calls, no production persistence beyond the sandbox account's bounded state.
3. Holding an active AAO membership at an API-access tier.
-That's it. The compliance heartbeat runs the same storyboards as (Spec), but targets the registered production URL with `account.sandbox: true` on every request. Pass → (Sandbox) qualifier issues.
+That's it. The compliance heartbeat runs the same storyboards as (Spec), but targets the registered production URL with sandbox-account references appropriate to the seller's account model. Pass → (Sandbox) qualifier issues.
-**Key requirement: sandbox-account isolation.** Sellers MUST persist a clear sandbox/live distinction at the account level. A request asserting `sandbox: true` against a live account MUST be refused with a structured error — see [#4028](https://github.com/adcontextprotocol/adcp/issues/4028) and the `comply-controller-mode-gate` storyboard for the canonical denial check. Cross-mode leakage is the failure mode (Sandbox) attests against.
+**Key requirement: sandbox-account isolation.** Sellers MUST persist a clear sandbox/live distinction at the account level. They MUST NOT treat a request flag as permission to relabel a live account: `sandbox: true` selects the sandbox natural key, and an `account_id` retains the stored mode of that pre-existing account. An unresolved or mismatched reference must fail with a structured error — see [#4028](https://github.com/adcontextprotocol/adcp/issues/4028) and the `comply-controller-mode-gate` storyboard for the canonical denial check. Cross-mode leakage is the failure mode (Sandbox) attests against.
## Decentralized verification
@@ -160,7 +160,7 @@ The token claims:
`adcp_version` is the AdCP release this badge was issued against (`MAJOR.MINOR`). Pairs with the `(agent_url, role, adcp_version)` identity used by the badge URL routes. **Verifiers MUST check `adcp_version` against the AdCP version they care about** — a 3.0 token presented as proof of 3.1 conformance is not authoritative. The signed claim is shape-validated at signing time (`^[1-9][0-9]*\.[0-9]+$`); verifiers SHOULD apply the same regex defensively.
-`verification_modes` is the array of axes earned. `["spec"]` for test-deployment storyboard pass only; `["spec", "sandbox"]` for agents whose production endpoint also passes under sandbox-flagged traffic. `protocol_version` is the full semver of the spec build the badge was tested against — informational metadata for support and audits.
+`verification_modes` is the array of axes earned. `["spec"]` for test-deployment storyboard pass only; `["spec", "sandbox"]` for agents whose production endpoint also passes under sandbox-account traffic. `protocol_version` is the full semver of the spec build the badge was tested against — informational metadata for support and audits.
Runner coverage is reported separately from badge mode. A production-path sandbox run can have zero failed assertions and still be `partial` when selected controller-dependent scenarios skip because the production endpoint correctly omits [`comply_test_controller`](/docs/building/by-layer/L3/comply-test-controller). That is useful evidence, but badge issuance uses the registry's per-storyboard status, not-selected reasons, and skip reasons, not just the failed-step count. Expected sandbox-mode exclusions, optional-capability skips, and missing-required-surface skips remain visible as separate signals so buyers can tell "not part of this run" from "selected but not executed" from "not implemented by this seller."
@@ -178,9 +178,9 @@ Verification is continuously re-evaluated, not a one-time certificate.
- **Recovery** — passing storyboards reissue (Spec) automatically.
### (Sandbox)
-- **Issued** — first heartbeat whose Sandbox verification profile passes against the registered production URL under `account.sandbox: true` + active membership. The profile requires all buyer-visible sandbox-path assertions to pass, treats production-forbidden controller phases as not-selected run-mode exclusions, and keeps optional-capability skips visible.
+- **Issued** — first heartbeat whose Sandbox verification profile passes against the registered production URL using verified sandbox-account references + active membership. The profile requires all buyer-visible sandbox-path assertions to pass, treats production-forbidden controller phases as not-selected run-mode exclusions, and keeps optional-capability skips visible.
- **Active** — re-checked every heartbeat; JWT auto-renewed.
-- **Degraded** — first Sandbox-profile regression starts a 48-hour grace; the badge continues to render (Sandbox) while the operator investigates. Cross-mode leakage (a sandbox request producing real-world side effects, or a live account accepting sandbox-flagged traffic) MAY skip the grace period and revoke immediately — that's the (Sandbox) attestation's whole point.
+- **Degraded** — first Sandbox-profile regression starts a 48-hour grace; the badge continues to render (Sandbox) while the operator investigates. Cross-mode leakage (a sandbox request producing real-world side effects, or a production account ID being processed as sandbox) MAY skip the grace period and revoke immediately — that's the (Sandbox) attestation's whole point.
- **Revoked** — 48h continuous failure → `(Sandbox)` qualifier drops. (Spec), if held, is unaffected.
- **Recovery** — passing the Sandbox profile reissues (Sandbox).
@@ -216,8 +216,8 @@ not cacheable and are the authoritative real-time source.
A seller MAY hold:
-- **(Spec) only** — storyboards pass on a test-mode endpoint; (Sandbox) not enrolled, or the production endpoint has not yet passed under sandbox-flagged traffic. Common for test agents, sandboxes, and pre-production rollouts.
-- **(Sandbox) only** — storyboards pass against the registered production endpoint under `account.sandbox: true`. Common for production-only platforms with no separate test-mode surface.
+- **(Spec) only** — storyboards pass on a test-mode endpoint; (Sandbox) not enrolled, or the production endpoint has not yet passed under sandbox-account traffic. Common for test agents, sandboxes, and pre-production rollouts.
+- **(Sandbox) only** — storyboards pass against the registered production endpoint using verified sandbox-account references. Common for production-only platforms with no separate test-mode surface.
- **(Spec + Sandbox)** — the strongest claim. Both axes verified independently.
- **Neither**
@@ -292,7 +292,7 @@ Returns HTML and Markdown snippets that wrap the SVG in a link back to the agent
The agent registry surfaces filters on either axis independently:
- **"Show me agents that implement AdCP correctly"** → filter by `verification_modes contains 'spec'`
-- **"Show me production endpoints that pass under sandbox-flagged traffic"** → filter by `verification_modes contains 'sandbox'`
+- **"Show me production endpoints that pass under sandbox-account traffic"** → filter by `verification_modes contains 'sandbox'`
- **"Show me agents with both"** → filter by both
Both queries are valid. Buyers comparing options use (Sandbox); orchestrator developers integrating new agents use (Spec).
@@ -334,18 +334,18 @@ When AgenticAdvertising.org serves brand.json data for a registered brand, agent
1. Hold an active AAO membership with API-access tier.
2. Declare your `supported_protocols` and `specialisms` in `get_adcp_capabilities` (same as (Spec)).
-3. Register your **production `agent_url`** with AgenticAdvertising.org. The compliance heartbeat will target it with `account.sandbox: true` on every storyboard request.
-4. Implement the sandbox-account gate in your production stack: verify the targeted account is sandbox in your persisted records (not by trusting the field), and process the request with full schema/lifecycle correctness while producing **zero real-world side effects** — no real spend, no real ad-server orders, no third-party platform calls, no production persistence beyond the bounded sandbox account state.
+3. Register your **production `agent_url`** with AgenticAdvertising.org. The compliance heartbeat will use natural-key references with `sandbox: true` or preverified sandbox `account_id` references, according to your account model.
+4. Implement the sandbox-account gate in your production stack: resolve a natural-key sandbox selector to the distinct sandbox account, or verify the stored mode of the selected account ID, and process that account with full schema/lifecycle correctness while producing **zero real-world side effects** — no real spend, no real ad-server orders, no third-party platform calls, no production persistence beyond the bounded sandbox account state.
5. Pass the Sandbox verification profile: buyer-visible sandbox-path storyboards must pass, not-selected run-mode exclusions are allowed, and optional-capability skips remain visible as scope choices. The production endpoint is not expected to expose [`comply_test_controller`](/docs/building/by-layer/L3/comply-test-controller); selected controller-dependent deterministic phases are not a Sandbox-badge blocker when the controller is absent from production as required.
6. If you run a shared production surface that exposes `comply_test_controller` to sandbox principals, pass the [`comply-controller-mode-gate`](https://adcontextprotocol.org/compliance/latest/universal/comply-controller-mode-gate) check for that surface. Canonical two-deployment sellers satisfy the same isolation requirement by not advertising the controller on production at all.
-7. The AgenticAdvertising.org compliance heartbeat issues **AAO Verified (Sandbox)** when the Sandbox profile passes against the registered URL with sandbox-flagged traffic.
+7. The AgenticAdvertising.org compliance heartbeat issues **AAO Verified (Sandbox)** when the Sandbox profile passes against the registered URL with sandbox-account traffic.
Same storyboards as (Spec). Same heartbeat cadence. Different attestation surface: prod, with sandbox flagging, instead of any-registered-endpoint.
## What AAO Verified is not
- **Not a regulatory or financial attestation.** SOC 2, ISO 27001, ISAE 3402 and similar frameworks address operational and financial-control posture — distinct questions, with their own audit paths. AAO Verified is wire-and-delivery correctness for AdCP.
-- **Not hard ground-truth reconciliation.** (Sandbox) attests the production code path handles sandbox-flagged traffic correctly across the protocol surface. It does not reconcile real-money AdCP-reported numbers against the seller's internal ad-server dashboard under live traffic. Hard reconciliation is a separate kind of attestation tracked outside the (Sandbox) tier.
+- **Not hard ground-truth reconciliation.** (Sandbox) attests the production code path handles sandbox-account traffic correctly across the protocol surface. It does not reconcile real-money AdCP-reported numbers against the seller's internal ad-server dashboard under live traffic. Hard reconciliation is a separate kind of attestation tracked outside the (Sandbox) tier.
- **Not certification beyond AAO membership.** The [AgenticAdvertising.org certification program](/docs/learning/overview) composes with AAO Verified — verification is necessary input to certification, but verification is not certification itself.
- **Not a SLA.** AAO Verified does not guarantee uptime, latency, or commercial outcomes. It attests that the seller's AdCP surface continuously reflects real delivery; commercial reliability is between buyer and seller.
- **Not a substitute for due diligence.** Buyers SHOULD still vet sellers' contractual terms, billing posture, governance practices, and incident-response posture independently. AAO Verified is one input, not the whole picture.
@@ -354,7 +354,7 @@ Same storyboards as (Spec). Same heartbeat cadence. Different attestation surfac
AAO Verified (Sandbox) rests on a small set of normative AdCP spec elements:
-- **[`account.sandbox` schema gate (#3755 / #4382)](https://github.com/adcontextprotocol/adcp/issues/3755)** — pins sandbox intent on account references and gives the seller a protocol-level flag to verify against persisted account mode. The seller-side gate is the load-bearing control; the request flag is not trusted by itself.
+- **[`account.sandbox` schema gate (#3755 / #4382)](https://github.com/adcontextprotocol/adcp/issues/3755)** — makes sandbox mode part of a natural-key account reference. Account-ID references cannot carry this field; they retain the selected account's persisted mode. The seller-side account-resolution gate is the load-bearing control.
- **[`comply-controller-mode-gate` storyboard (#4028 / #4384)](https://github.com/adcontextprotocol/adcp/issues/4028)** — verifies sellers correctly refuse controller dispatch against live-mode accounts when they choose a shared surface that exposes the controller to sandbox principals. Canonical production deployments satisfy this by not exposing the controller at all.
- **[UNKNOWN_SCENARIO grading (#4226 / #4228)](https://github.com/adcontextprotocol/adcp/issues/4226)** — sellers MAY implement controller scenarios selectively in dev/staging; the runner grades absent operations as coverage gaps rather than failures. Controller is dev-only per the (Sandbox) framing.
diff --git a/docs/building/verification/compliance-catalog.mdx b/docs/building/verification/compliance-catalog.mdx
index 6264c46471..bf946ff090 100644
--- a/docs/building/verification/compliance-catalog.mdx
+++ b/docs/building/verification/compliance-catalog.mdx
@@ -83,6 +83,7 @@ Specialisms are grouped below by parent protocol.
| `sales-proposal-mode` | deprecated | **Deprecated in 3.1.** Drop this claim and replace with `sales-guaranteed` + `media_buy.supports_proposals: true`. See [#3823](https://github.com/adcontextprotocol/adcp/issues/3823). |
| `sales-catalog-driven` | stable | Catalog-driven commerce with conversion tracking |
| `sales-broadcast-tv` | stable | Broadcast linear TV with guaranteed inventory and FCC cancellation rules |
+| `sales-dooh` | stable | Digital out-of-home, non-guaranteed venue and screen inventory |
| `sales-social` | stable | Social media advertising platform with self-service flows |
| `governance-aware-seller` | stable | Seller composes with the buyer's campaign-governance agent after baseline registration — calls [`check_governance`](/docs/governance/campaign/tasks/check_governance) and propagates approvals, conditions, and denials unchanged. Optional claim for the full governance-check loop. |
| `audience-sync` | stable | Syncs buyer-provided audience segments into a platform for activation (uses [`sync_audiences`](/docs/media-buy/task-reference/sync_audiences), [`list_accounts`](/docs/accounts/tasks/list_accounts)) |
@@ -142,14 +143,18 @@ The `sales-*` specialisms are not mutually exclusive — a hybrid platform with
-Three specialisms apply to specific delivery channels and have their own storyboards. If you only sell one of these channel types, claim only the matching specialism. If you also sell general display or video inventory outside these channels, continue to Step 2.
+Four specialisms apply to specific delivery channels and have their own storyboards. If you only sell one of these channel types, claim only the matching specialism. If you also sell general display or video inventory outside these channels, continue to Step 2.
| If you operate… | Claim |
|---|---|
| Broadcast linear TV with FCC cancellation rules | `sales-broadcast-tv` |
| Catalog-driven dynamic ads (product listings, restaurant menus, hotel listings, local commerce) | `sales-catalog-driven` |
+| Non-guaranteed digital out-of-home venue and screen inventory | `sales-dooh` |
+| Guaranteed-only digital out-of-home inventory | `sales-guaranteed` |
| Social platform with platform-managed creative | `sales-social` |
+`sales-dooh` is the non-guaranteed DOOH profile. Guaranteed-only DOOH sellers still route `dooh` briefs through their portfolio declaration, but claim `sales-guaranteed` until a dedicated guaranteed DOOH profile exists.
+
@@ -289,6 +294,7 @@ The kebab↔snake swap between wire specialism IDs and storyboard categories is
| Specialism ID (wire) | Channel / tool family | Storyboard category | Variant scenarios |
|----------------------|-----------------------|---------------------|-------------------|
| `sales-broadcast-tv` | `channels: ['linear_tv']` | [`sales_broadcast_tv`](/compliance/latest/specialisms/sales-broadcast-tv/index.yaml) | — |
+| `sales-dooh` | `channels: ['dooh']` | [`sales_dooh`](/compliance/latest/specialisms/sales-dooh/index.yaml) | — |
| `sales-social` | `channels: ['social']` | [`sales_social`](/compliance/latest/specialisms/sales-social/index.yaml) | — |
| `audience-sync` | [`sync_audiences`](/docs/media-buy/task-reference/sync_audiences) tool | [`audience_sync`](/compliance/latest/specialisms/audience-sync/index.yaml) | — |
| `property-lists` | `property_list` tools | [`property_lists`](/compliance/latest/specialisms/property-lists/index.yaml) | — |
diff --git a/docs/building/verification/validate-your-agent.mdx b/docs/building/verification/validate-your-agent.mdx
index 3004cbbb9b..cd11c03dfb 100644
--- a/docs/building/verification/validate-your-agent.mdx
+++ b/docs/building/verification/validate-your-agent.mdx
@@ -180,7 +180,7 @@ Each step builds confidence. Storyboards prove protocol compliance. RFP and IO t
## Sandbox mode
-All storyboard runs use sandbox mode by default. The storyboard runner sets `sandbox: true` on every account reference, so your agent processes requests without real platform calls or spend.
+All storyboard runs use sandbox mode by default. For buyer-declared accounts, the runner includes `sandbox: true` in every natural-key account reference. For account-ID namespaces, it first resolves and verifies a sandbox account, then sends `{ "account_id": "..." }` without a sandbox flag. In either model, your agent processes the selected sandbox account without real platform calls or spend.
Your agent should declare sandbox support in [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities):
@@ -194,7 +194,7 @@ Your agent should declare sandbox support in [`get_adcp_capabilities`](/docs/pro
When a request references a sandbox account, your agent MUST NOT persist production state or cause real-world side effects — no real orders, no real billing, no real ad platform API calls. Return realistic response shapes with simulated data and include `sandbox: true` in success responses.
-See [Sandbox mode](/docs/media-buy/advanced-topics/sandbox) for full implementation details and the two account model paths (implicit vs explicit).
+See [Sandbox mode](/docs/media-buy/advanced-topics/sandbox) for full implementation details and the natural-key and account-ID paths.
## Verifying cross-instance state
diff --git a/docs/creative/channels/dooh.mdx b/docs/creative/channels/dooh.mdx
index 74b5d94251..0e3faa0fd7 100644
--- a/docs/creative/channels/dooh.mdx
+++ b/docs/creative/channels/dooh.mdx
@@ -35,4 +35,130 @@ DOOH uses `image` or `video_hosted`, narrowed to the screen's pixel dimensions,
Most DOOH renderers cannot fire browser pixels or accept clickthrough interaction. Declare only slots and macros the runtime can actually support. Delivery and measurement come from screen logs, venue counts, panels, or other sources on the product/package.
+## Declaring a non-guaranteed DOOH seller
+
+DOOH uses three existing declarations for three different jobs:
+
+- `media_buy.portfolio.primary_channels` routes suitable briefs to the seller.
+- `specialisms: ["sales-dooh"]` opts into the executable non-guaranteed DOOH compliance storyboard.
+- Each returned product carries `channels: ["dooh"]` and its own `delivery_type`.
+
+A seller whose inventory is exclusively non-guaranteed DOOH declares:
+
+```json
+{
+ "supported_protocols": ["media_buy"],
+ "specialisms": ["sales-dooh"],
+ "media_buy": {
+ "portfolio": {
+ "publisher_domains": ["metro-media.example"],
+ "primary_channels": ["dooh"]
+ }
+ }
+}
+```
+
+`sales-dooh` is explicitly the **digital out-of-home — non-guaranteed** profile, not a universal claim for every DOOH seller. A guaranteed-only DOOH seller claims `sales-guaranteed` and routes `dooh` briefs through `media_buy.portfolio.primary_channels`; it does not claim `sales-dooh` unless a future guaranteed DOOH profile defines that contract. A seller that also offers general display or video auction inventory claims `sales-non-guaranteed` as well; the generic claim is not required merely because the DOOH products use `delivery_type: "non_guaranteed"`.
+
+## Model venue and screen inventory with products and placements
+
+DOOH does not require a separate Product subtype. Use publisher properties for the inventory estate, public placements for buyer-addressable screens or loop positions, and canonical format options for creative eligibility:
+
+```json
+{
+ "product_id": "metro_concourse_portrait_loop",
+ "name": "Metro concourse portrait loop",
+ "description": "Portrait screens bundled across fictional metro concourses.",
+ "channels": ["dooh"],
+ "delivery_type": "non_guaranteed",
+ "publisher_properties": [{
+ "publisher_domain": "metro-media.example",
+ "selection_type": "by_tag",
+ "property_tags": ["transit_venues"]
+ }],
+ "placements": [{
+ "kind": "seller_inline",
+ "publisher_domain": "metro-media.example",
+ "placement_id": "central_concourse_portrait_screens",
+ "name": "Central concourse portrait screens",
+ "mode": "included",
+ "tags": ["transit", "concourse", "portrait"],
+ "identifiers": [
+ { "type": "venue_id", "value": "metro:central-concourse" }
+ ],
+ "dooh_placement_attributes": {
+ "slot_duration_seconds": 10,
+ "loop_duration_seconds": 80,
+ "screen_resolution": { "width": 1080, "height": 1920 },
+ "motion": "full_motion"
+ }
+ }],
+ "format_options": [{
+ "format_option_id": "portrait_image_1080x1920",
+ "format_kind": "image",
+ "params": {
+ "width": 1080,
+ "height": 1920,
+ "max_file_size_kb": 10240
+ }
+ }],
+ "pricing_options": [{
+ "pricing_option_id": "metro_concourse_cpm",
+ "pricing_model": "cpm",
+ "currency": "USD",
+ "floor_price": 8
+ }],
+ "reporting_capabilities": {
+ "available_reporting_frequencies": ["daily"],
+ "expected_delay_minutes": 240,
+ "timezone": "UTC",
+ "supports_webhooks": false,
+ "available_metrics": ["impressions", "spend", "plays", "dooh_metrics"],
+ "date_range_support": "date_range"
+ }
+}
+```
+
+Set a placement to `targetable` only when the buyer can select it through the product's declared placement-selection overlay. Use `included` for screens or loop inventory already bundled into the product. Seller-private player IDs and ad-server mappings are not public placement IDs.
+
+`dooh_placement_attributes` describes physical inventory and scheduling facts. It does not declare creative eligibility: in the example, the screen is capable of full motion but this product accepts only the image declared in `format_options`. Optional `identifiers` attach public venue, screen, or taxonomy identities when they exist; they do not require a seller to manufacture a persistent screen identifier.
+
+If loop position, share of voice, or scheduling changes what the buyer can purchase, expose it as a distinct product or public placement with the applicable commercial terms. Put canonical slot and loop duration on `dooh_placement_attributes`. Existing fixed-allocation DOOH offers can also declare `pricing_options[].parameters` on a `flat_rate` option with `type: "dooh"`, plus `sov_percentage`, `min_plays_per_hour`, `venue_package`, `duration_hours`, and `daypart`. The deprecated pricing-layer copy of `loop_duration_seconds`, when present on a migrated offer, must agree with the placement declaration. Guaranteed-only offers use the `sales-guaranteed` profile.
+
+For CPM discovery, use existing `forecast`, `delivery_measurement`, and `measurement_terms` fields to explain projected plays or impressions, the audience currency and methodology, and which measurement governs billing. Placement attributes make slot and loop duration machine-readable, but AdCP does not yet have an equivalent standard allocation or share-of-voice block on auction CPM pricing. Until that gap is addressed, put material allocation terms in distinct product or placement identity plus buyer-readable descriptions, and do not imply that free text is structurally comparable across sellers. When inventory is assembled only for one discovery request, return an `is_custom: true` product with `expires_at` rather than inventing a persistent catalog identity.
+
+## Canonical formats on every 3.2 path
+
+Every AdCP 3.2 product-list response authors creative requirements in `format_options[]`. The shared 3.x schema still reads deprecated `format_ids[]` for migration, but schema validity alone does not make a named format resolvable.
+
+That response contract is the same whether the seller authored, imported, or passed through the underlying inventory; buyers do not need a separate product-path capability flag. A seller receiving legacy references first resolves them through its authoritative format catalog and translates the constraints losslessly or with explicit narrowing. It must not guess dimensions, slots, codecs, durations, or other constraints from an ID. If resolution or safe translation fails, it omits or rejects the product with an actionable error rather than exposing an unresolved legacy-only product. The `sales-dooh` storyboard checks the canonical declaration on the exact 3.2 product-list path.
+
+## Optional attention without impression identity
+
+Attention measurement is optional and is not required to claim `sales-dooh`. When a product does offer anonymous people-in-frame or gaze/attention measurement, do not coerce it into `impressions`. Declare the measurement vendor's metrics under `reporting_capabilities.vendor_metrics`, then report values through `vendor_metric_values`:
+
+```json
+{
+ "reporting_capabilities": {
+ "vendor_metrics": [
+ { "vendor": { "domain": "vision-metrics.example" }, "metric_id": "people_in_frame" },
+ { "vendor": { "domain": "vision-metrics.example" }, "metric_id": "gaze_attention_seconds" }
+ ]
+ }
+}
+```
+
+The vendor's measurement-agent catalog defines units, methodology, accreditations, and whether the metric is observed or modeled. The existing `media_buy_seller/vendor_metric_accountability` scenario verifies declaration, commitment, and delivery for sellers whose conformance profile selects that contract; it is not part of the core `sales-dooh` claim. Ordinary play-reporting DOOH sellers do not need vendor metrics. AdCP does not require a persistent person or device identifier for these aggregate observations. A seller must not manufacture one merely to fit an impression-oriented workflow. Standard DOOH delivery facts such as raw plays, screens used, screen time, share of voice, and venue breakdown use `plays` and `dooh_metrics`.
+
+## Storyboard coverage
+
+The [`sales-dooh` storyboard](/compliance/latest/specialisms/sales-dooh/index.yaml) verifies the channel-specific path:
+
+1. The seller declares `sales-dooh` and routes `dooh` briefs.
+2. Exact product listing returns non-guaranteed DOOH inventory with canonical screen formats, publisher scope, and public placements.
+3. When supported, the buyer preflights a screen-compatible creative against the product; the required flow syncs it and creates a sandbox media buy using the returned product, pricing option, and bid.
+4. Deterministically injected play and screen activity reconciles to the purchased package through `plays` and `dooh_metrics`.
+
+Generic media-buy storyboards continue to test lifecycle and error semantics. The DOOH specialism does not require vendor attention measurement, clicks, conversions, browser pixels, or persistent audience identifiers.
+
See [Canonical formats](/docs/creative/canonical-formats), [Accessibility](/docs/creative/accessibility), and [Media products](/docs/media-buy/product-discovery/media-products).
diff --git a/docs/learning/specialist/media-buy.mdx b/docs/learning/specialist/media-buy.mdx
index 55ef3cbe34..0b55db639a 100644
--- a/docs/learning/specialist/media-buy.mdx
+++ b/docs/learning/specialist/media-buy.mdx
@@ -27,6 +27,7 @@ Agents in the `media_buy` domain declare specific flows they support via the `sp
| `sales-proposal-mode` | deprecated | **Deprecated in 3.1.** Replace with `sales-guaranteed` + `media_buy.supports_proposals: true`. |
| `sales-catalog-driven` | stable | Catalog-driven commerce with conversion tracking |
| `sales-broadcast-tv` | stable | Broadcast linear TV with guaranteed inventory and FCC cancellation rules |
+| `sales-dooh` | stable | Non-guaranteed digital out-of-home venue and screen inventory |
| `sales-social` | stable | Social media advertising platform with self-service flows |
See the [Compliance Catalog](/docs/building/verification/compliance-catalog) for the full taxonomy and the [`specialism` enum](https://adcontextprotocol.org/schemas/v3/enums/specialism.json) for the authoritative list.
diff --git a/docs/learning/tracks/publisher.mdx b/docs/learning/tracks/publisher.mdx
index 2c0a613972..1f582979ef 100644
--- a/docs/learning/tracks/publisher.mdx
+++ b/docs/learning/tracks/publisher.mdx
@@ -237,6 +237,7 @@ Declare `media_buy` in `supported_protocols` and choose the specialism that matc
- `sales-proposal-mode` — **deprecated in 3.1**; use `sales-guaranteed` + `media_buy.supports_proposals: true` instead
- `sales-catalog-driven` — catalog-driven commerce with conversion tracking
- `sales-broadcast-tv` — broadcast linear TV
+- `sales-dooh` — non-guaranteed digital out-of-home venue and screen inventory
- `sales-social` — social platform with self-service flows
See the [Compliance Catalog](/docs/building/verification/compliance-catalog) for the full list.
diff --git a/docs/media-buy/advanced-topics/pricing-models.mdx b/docs/media-buy/advanced-topics/pricing-models.mdx
index 16bfea69f4..8d35eb6ecc 100644
--- a/docs/media-buy/advanced-topics/pricing-models.mdx
+++ b/docs/media-buy/advanced-topics/pricing-models.mdx
@@ -456,7 +456,6 @@ Revenue share is channel-independent. Affiliate is the immediate use case, but s
"daypart": "morning_commute",
"duration_hours": 4,
"sov_percentage": 25,
- "loop_duration_seconds": 15,
"estimated_impressions": 120000
}
}
@@ -466,7 +465,7 @@ Revenue share is channel-independent. Affiliate is the immediate use case, but s
**DOOH parameters** (`parameters.type: "dooh"`):
- `sov_percentage`: Guaranteed share of voice as a percentage (0-100)
-- `loop_duration_seconds`: Duration of ad loop rotation in seconds
+- `loop_duration_seconds`: Deprecated compatibility copy of the placement's `dooh_placement_attributes.loop_duration_seconds`. New integrations read the placement-level value; when both are present they must agree. Offers with different loop durations use distinct placements or products rather than pricing-option-specific copies.
- `min_plays_per_hour`: Minimum guaranteed plays per hour
- `venue_package`: Named collection of screens
- `duration_hours`: Duration of the slot in hours (e.g., 24 for a full-day takeover)
diff --git a/docs/media-buy/advanced-topics/sandbox.mdx b/docs/media-buy/advanced-topics/sandbox.mdx
index e2ca8f45c8..1819326e86 100644
--- a/docs/media-buy/advanced-topics/sandbox.mdx
+++ b/docs/media-buy/advanced-topics/sandbox.mdx
@@ -10,6 +10,10 @@ Sandbox mode lets buyers test the full media buying lifecycle — discovery, cam
Sandbox is **account-level**, not per-request. Once a request references a sandbox account, the entire request is treated as sandbox. This eliminates the risk of accidentally mixing real and test traffic in a multi-step flow.
+
+`account.sandbox` in `get_adcp_capabilities` only advertises support; it does not turn the next media-buy request into a test request. In a natural-key account reference, `sandbox: true` is part of the key that selects the distinct sandbox account and must be repeated on every account-scoped request. In an account-ID namespace, sandbox status belongs to the pre-existing account selected by `account_id`; adding a sandbox boolean to `{ "account_id": "..." }` is invalid and cannot convert a production account. Discover or otherwise obtain the sandbox ID, verify the returned account has `sandbox: true`, and then use only that ID. Resolve and verify the sandbox account before any spend-committing call. Omitting the selector from a natural key defaults to production and can book real inventory.
+
+
## Capabilities discovery
Sellers declare sandbox support in `get_adcp_capabilities`:
@@ -218,7 +222,7 @@ You can combine them — `dry_run: true` on a sandbox account previews the sync
The `X-Dry-Run`, `X-Test-Session-ID`, and `X-Mock-Time` HTTP headers are **deprecated**. Sandbox mode replaces them as a protocol-level parameter.
- **Sellers MUST NOT** alter behavior based on these headers. Sandbox mode is determined solely by the account reference. Sellers SHOULD ignore the headers entirely and MAY log a deprecation warning to help buyers identify stale integrations.
-- **Buyers MUST NOT** rely on these headers to prevent production side effects. Only `sandbox: true` on the account reference guarantees sandbox semantics.
+- **Buyers MUST NOT** rely on these headers to prevent production side effects. Use a natural-key account reference with `sandbox: true`, or a preverified sandbox `account_id`; an account ID cannot carry a sandbox flag or convert a production account.
## Seller implementation
diff --git a/docs/media-buy/product-discovery/media-products.mdx b/docs/media-buy/product-discovery/media-products.mdx
index f829c76327..22d3d46b90 100644
--- a/docs/media-buy/product-discovery/media-products.mdx
+++ b/docs/media-buy/product-discovery/media-products.mdx
@@ -54,6 +54,12 @@ Products declare which pricing models they support. Buyers select a specific pri
- `conversion_tracking` (object, optional): Conversion event tracking capabilities. Presence indicates the product supports `optimization_goals` with `kind: "event"`. See [Conversion tracking](#conversion-tracking-1).
- `product_card` (object, optional): Visual card definition for displaying this product in user interfaces. See [Product Cards](#product-cards).
+### Pass-through product sources
+
+Schema validity is not the same as format resolvability. The shared 3.x Product schema still accepts a legacy-only `format_ids[]` product so older sellers remain readable, but each `{agent_url, id}` is only useful when the consuming path also fetches and resolves that named-format catalog.
+
+AdCP 3.2 intermediaries that import or pass through upstream products MUST require upstream `format_options[]`, or fetch and authoritatively resolve every referenced legacy format before translating it into canonical declarations. That translation MUST preserve the resolved constraints losslessly or narrow them explicitly; intermediaries MUST NOT guess dimensions, slots, codecs, durations, or other constraints from a format ID. If authoritative resolution or safe translation fails, omit or reject the product with an actionable error. Intermediaries MUST NOT pass through a legacy-only product on a source path that does not resolve its format catalog, and buyers MUST NOT infer legacy resolvability from schema validation alone.
+
### Metric optimization
Products that support `optimization_goals` with `kind: "metric"` declare their capabilities in `metric_optimization`. No event source or conversion tracking setup is required for metric goals — the seller tracks these metrics natively.
@@ -337,6 +343,25 @@ The field is an array because a sellable product can aggregate multiple surfaces
This is a discovery signal, not a verification claim. Buyers can filter for products that can satisfy a requested surface with `get_products.filters.social_placement_surfaces`, but sellers should not return mixed, non-targetable bundles unless they can constrain delivery to the requested surface during planning or purchase.
+#### DOOH placement attributes
+
+Digital out-of-home placements can declare structured selling-unit metadata via `dooh_placement_attributes` at the placement level (both in product placements and in `adagents.json` placement definitions). These fields describe the physical screen and ad-loop characteristics that buyers need for creative production and share-of-voice calculations:
+
+| Field | Type | Meaning |
+|---|---|---|
+| `slot_duration_seconds` | integer | Duration of one ad slot in seconds (e.g., 10, 15, 30) |
+| `loop_duration_seconds` | integer | Duration of the full ad loop rotation in seconds. Buyers derive nominal slot share as `slot_duration_seconds / loop_duration_seconds`. This is the canonical source; the pricing-layer field in `flat-rate-option` is superseded |
+| `screen_resolution` | object | Physical screen resolution (`{ width, height }` in pixels). Buyers derive aspect ratio from `width / height` |
+| `motion` | string | Motion capability of the screen: `full_motion` (video), `partial_motion` (animated stills), or `static` (images only) |
+
+All fields are optional. A single screen/frame placement can include all fields; a package or network placement should include only fields that are uniform across the included inventory. After publisher and product declarations are resolved, `slot_duration_seconds` MUST NOT exceed `loop_duration_seconds`.
+
+These are inventory facts, not an alternative creative contract. Effective canonical `format_options` remains authoritative for accepted dimensions, durations, asset types, and codecs. A physical screen can therefore report a larger `screen_resolution` than the content region declared by its formats, and a full-motion screen can still offer a static-only product.
+
+For `kind: "publisher_ref"`, resolve the publisher placement before applying product detail. Product-level `slot_duration_seconds` and `loop_duration_seconds` override publisher defaults for that specific offer; omitted values inherit. `screen_resolution` and `motion` are intrinsic publisher facts, so repeated product values MUST match the publisher declaration. Treat mismatches and an invalid effective slot-to-loop ratio as conformance errors rather than silently choosing one source.
+
+Placements can also carry `identifiers[]` using the same `{ type, value }` shape as property identifiers. DOOH placements commonly use `venue_id`, `screen_id`, and `openooh_venue_type` identifier types. Externally governed IDs should be authority-prefixed (e.g., `geopath:30961`). OpenOOH classifications include the taxonomy version (`openooh-1.1:20501`), since a bare category number does not identify which revision defined it. For publisher references, union publisher and product identifiers by exact `(type, value)`; product omission does not remove a publisher identifier.
+
#### Format precedence with placements
Product-level `format_options` define the creative formats accepted by the product as a whole. Placement-level `format_options`, whether returned inline on the product placement or inherited from a public publisher placement declaration, only narrow that product-wide set for the specific placement. Deprecated `format_ids` may appear only as a 3.x compatibility projection of the same declarations.
diff --git a/docs/registry/maintaining-your-agent.mdx b/docs/registry/maintaining-your-agent.mdx
index 52c120f429..f530dfd56b 100644
--- a/docs/registry/maintaining-your-agent.mdx
+++ b/docs/registry/maintaining-your-agent.mdx
@@ -40,7 +40,7 @@ Both qualifiers run the same storyboards on the same ~1h heartbeat. The differen
| Qualifier | Runner targets | What it attests |
|---|---|---|
| **(Spec)** | Any endpoint you register — test deployment, local dev, sandbox-only stack | AdCP wire format and protocol semantics are correct |
-| **(Sandbox)** | Your registered **production** `agent_url` with `account.sandbox: true` on every request | Your production code path honors sandbox flagging with zero real-world side effects |
+| **(Sandbox)** | Your registered **production** `agent_url`, using natural-key references with `sandbox: true` or preverified sandbox `account_id` references | Your production code path keeps sandbox accounts isolated with zero real-world side effects |
See [AAO Verified](/docs/building/verification/aao-verified) for complete eligibility and attestation details.
diff --git a/server/src/addie/services/compliance-testing.ts b/server/src/addie/services/compliance-testing.ts
index 6d3f3e529e..6167a7ae13 100644
--- a/server/src/addie/services/compliance-testing.ts
+++ b/server/src/addie/services/compliance-testing.ts
@@ -1207,6 +1207,7 @@ const SPECIALISM_CATALOG: Record = {
'audience-sync': { protocol: 'media-buy', storyboard_id: 'audience_sync' },
'sales-broadcast-tv': { protocol: 'media-buy', storyboard_id: 'sales_broadcast_tv' },
'sales-catalog-driven': { protocol: 'media-buy', storyboard_id: 'sales_catalog_driven' },
+ 'sales-dooh': { protocol: 'media-buy', storyboard_id: 'sales_dooh' },
'sales-guaranteed': { protocol: 'media-buy', storyboard_id: 'sales_guaranteed' },
'sales-non-guaranteed': { protocol: 'media-buy', storyboard_id: 'sales_non_guaranteed' },
'sales-proposal-mode': { protocol: 'media-buy', storyboard_id: 'sales_proposal_mode' },
diff --git a/server/src/creative-agent/task-handlers.ts b/server/src/creative-agent/task-handlers.ts
index 995be788ae..fd49813486 100644
--- a/server/src/creative-agent/task-handlers.ts
+++ b/server/src/creative-agent/task-handlers.ts
@@ -1068,9 +1068,17 @@ export function createCreativeAgentServer(agentBaseUrl: string, principalId = 'i
try {
const result = handler((args as ToolArgs) || {});
+ const reportedErrors = Array.isArray((result as { errors?: unknown[] }).errors)
+ ? (result as { errors: unknown[] }).errors.length
+ : 0;
return {
structuredContent: result,
- content: [{ type: 'text' as const, text: JSON.stringify(result) }],
+ content: [{
+ type: 'text' as const,
+ text: reportedErrors > 0
+ ? `${name} completed with ${reportedErrors} reported error${reportedErrors === 1 ? '' : 's'}.`
+ : `${name} completed successfully.`,
+ }],
};
} catch (err) {
const message = err instanceof Error ? err.message : 'Internal error';
diff --git a/server/src/services/adcp-taxonomy.ts b/server/src/services/adcp-taxonomy.ts
index 9c75e0ef85..357145f739 100644
--- a/server/src/services/adcp-taxonomy.ts
+++ b/server/src/services/adcp-taxonomy.ts
@@ -101,6 +101,7 @@ export type AdcpSpecialism =
| 'property-lists'
| 'sales-broadcast-tv'
| 'sales-catalog-driven'
+ | 'sales-dooh'
| 'sales-guaranteed'
| 'sales-non-guaranteed'
| 'sales-proposal-mode'
@@ -125,6 +126,7 @@ export const ADCP_SPECIALISMS: readonly AdcpSpecialism[] = [
'property-lists',
'sales-broadcast-tv',
'sales-catalog-driven',
+ 'sales-dooh',
'sales-guaranteed',
'sales-non-guaranteed',
'sales-proposal-mode',
diff --git a/server/src/training-agent/comply-test-controller.ts b/server/src/training-agent/comply-test-controller.ts
index 406125e168..f55199ebdf 100644
--- a/server/src/training-agent/comply-test-controller.ts
+++ b/server/src/training-agent/comply-test-controller.ts
@@ -232,9 +232,10 @@ export function getDeliverySimulationForPeriod(
for (const simulation of cumulative.datedSimulations) {
const timestamp = new Date(`${simulation.deliveryDate}T00:00:00.000Z`).getTime();
if (timestamp < start.getTime() || timestamp >= end.getTime()) continue;
- const { impressions, clicks, conversions, reportedSpend, ...extensions } = simulation.metrics;
+ const { impressions, clicks, plays, conversions, reportedSpend, ...extensions } = simulation.metrics;
filtered.impressions += impressions;
filtered.clicks += clicks;
+ if (plays !== undefined) filtered.plays = (filtered.plays ?? 0) + plays;
filtered.conversions += conversions;
filtered.reportedSpend.amount += reportedSpend.amount;
filtered.reportedSpend.currency = reportedSpend.currency;
@@ -285,6 +286,7 @@ function deliverySimulationSnapshot(
},
};
applyExtendedDeliveryParams(snapshot, params);
+ if (typeof params.plays === 'number') snapshot.plays = params.plays;
if (Array.isArray(params.vendor_metric_values)) {
snapshot.vendorMetricValues = params.vendor_metric_values;
}
@@ -353,6 +355,9 @@ function applyExtendedDeliveryParams(cumulative: ComplyDeliveryAccumulator, para
if (params.viewability && typeof params.viewability === 'object' && !Array.isArray(params.viewability)) {
cumulative.viewability = params.viewability as ComplyDeliveryAccumulator['viewability'];
}
+ if (isRecord(params.dooh_metrics)) {
+ cumulative.doohMetrics = params.dooh_metrics;
+ }
if (Array.isArray(params.not_yet_measurable_vendor_metrics)) {
cumulative.deferredVendorMetrics = normalizeVendorMetricIdentities(params.not_yet_measurable_vendor_metrics) ?? [];
}
@@ -382,6 +387,8 @@ function extendedDeliverySnapshot(cumulative: ComplyDeliveryAccumulator): Record
...(cumulative.commissionableValue !== undefined ? { commissionable_value: cumulative.commissionableValue } : {}),
...(cumulative.reachWindow ? { reach_window: cumulative.reachWindow } : {}),
...(cumulative.viewability ? { viewability: cumulative.viewability } : {}),
+ ...(cumulative.plays !== undefined ? { plays: cumulative.plays } : {}),
+ ...(cumulative.doohMetrics ? { dooh_metrics: cumulative.doohMetrics } : {}),
...(cumulative.deferredVendorMetrics ? { not_yet_measurable_vendor_metrics: cumulative.deferredVendorMetrics } : {}),
...(cumulative.vendorMetricValuesByPackage ? { vendor_metric_values_by_package: cumulative.vendorMetricValuesByPackage } : {}),
...(cumulative.deferredVendorMetricsByPackage ? { not_yet_measurable_vendor_metrics_by_package: cumulative.deferredVendorMetricsByPackage } : {}),
@@ -885,6 +892,9 @@ function createStore(session: SessionState, sessionKey: string, principal?: stri
cumulative.impressions += impressions;
cumulative.clicks += clicks;
+ if (typeof typedParams.plays === 'number') {
+ cumulative.plays = (cumulative.plays ?? 0) + typedParams.plays;
+ }
cumulative.conversions += conversions;
if (reportedSpend) {
cumulative.reportedSpend.amount += reportedSpend.amount;
@@ -910,6 +920,8 @@ function createStore(session: SessionState, sessionKey: string, principal?: stri
if (typedParams.frequency !== undefined) simulated.frequency = typedParams.frequency;
if (typedParams.reach_window !== undefined) simulated.reach_window = typedParams.reach_window;
if (typedParams.viewability !== undefined) simulated.viewability = typedParams.viewability;
+ if (typedParams.plays !== undefined) simulated.plays = typedParams.plays;
+ if (typedParams.dooh_metrics !== undefined) simulated.dooh_metrics = typedParams.dooh_metrics;
if (typedParams.is_final !== undefined) simulated.is_final = typedParams.is_final;
if (typedParams.finalized_at !== undefined) simulated.finalized_at = typedParams.finalized_at;
if (typedParams.measurement_window !== undefined) simulated.measurement_window = typedParams.measurement_window;
@@ -1625,12 +1637,17 @@ export async function handleComplyTestController(args: ToolArgs, ctx: TrainingCo
if (mb) {
if (
mb.packages.length > 1
- && (params.vendor_metric_values !== undefined || params.not_yet_measurable_vendor_metrics !== undefined)
+ && (
+ params.vendor_metric_values !== undefined
+ || params.not_yet_measurable_vendor_metrics !== undefined
+ || params.plays !== undefined
+ || params.dooh_metrics !== undefined
+ )
) {
return {
success: false,
error: 'INVALID_PARAMS',
- error_detail: 'Multi-package buys require package-scoped vendor metric values and deferrals',
+ error_detail: 'Multi-package buys require package-scoped simulation values; plays and dooh_metrics are supported only for single-package buys',
};
}
if (
@@ -1671,6 +1688,8 @@ export async function handleComplyTestController(args: ToolArgs, ctx: TrainingCo
if (params.commissionable_value !== undefined) simulatedExtras.commissionable_value = params.commissionable_value;
if (params.reach_window !== undefined) simulatedExtras.reach_window = params.reach_window;
if (params.viewability !== undefined) simulatedExtras.viewability = params.viewability;
+ if (params.plays !== undefined) simulatedExtras.plays = params.plays;
+ if (params.dooh_metrics !== undefined) simulatedExtras.dooh_metrics = params.dooh_metrics;
if (params.is_final !== undefined) simulatedExtras.is_final = params.is_final;
if (params.finalized_at !== undefined) simulatedExtras.finalized_at = params.finalized_at;
if (params.measurement_window !== undefined) simulatedExtras.measurement_window = params.measurement_window;
diff --git a/server/src/training-agent/index.ts b/server/src/training-agent/index.ts
index 1c89290370..6f2495f0b9 100644
--- a/server/src/training-agent/index.ts
+++ b/server/src/training-agent/index.ts
@@ -301,7 +301,7 @@ const TENANT_IDS = ['signals', 'sales', 'governance', 'creative', 'creative-buil
* `_training_agent_tenants` discovery extension. Mirrors the per-tenant
* config builders in `tenants/.ts`. */
const TENANT_SPECIALISMS: Record = {
- sales: ['sales-non-guaranteed', 'sales-guaranteed'],
+ sales: ['sales-non-guaranteed', 'sales-guaranteed', 'sales-dooh'],
signals: ['signal-marketplace', 'signal-owned'],
governance: [
'governance-spend-authority',
diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts
index abe03dba4f..b60ab77624 100644
--- a/server/src/training-agent/task-handlers.ts
+++ b/server/src/training-agent/task-handlers.ts
@@ -9523,6 +9523,8 @@ export async function handleGetMediaBuyDelivery(args: ToolArgs, ctx: TrainingCon
spend,
impressions,
clicks,
+ ...(mb.packages.length === 1 && simDelivery?.plays !== undefined ? { plays: simDelivery.plays } : {}),
+ ...(mb.packages.length === 1 && simDelivery?.doohMetrics ? { dooh_metrics: simDelivery.doohMetrics } : {}),
...audioMetrics,
...byCreative,
...(vendorMetricValues.length > 0 && { vendor_metric_values: vendorMetricValues }),
@@ -9684,6 +9686,12 @@ export async function handleGetMediaBuyDelivery(args: ToolArgs, ctx: TrainingCon
const simulatedViewability = simDelivery?.viewability
? { viewability: simDelivery.viewability }
: {};
+ const simulatedDoohMetrics = simDelivery
+ ? {
+ ...(simDelivery.plays !== undefined ? { plays: simDelivery.plays } : {}),
+ ...(simDelivery.doohMetrics ? { dooh_metrics: simDelivery.doohMetrics } : {}),
+ }
+ : {};
return {
reporting_period: {
@@ -9717,6 +9725,7 @@ export async function handleGetMediaBuyDelivery(args: ToolArgs, ctx: TrainingCon
...conversionTotals,
...conversionValueTotals,
...simulatedViewability,
+ ...simulatedDoohMetrics,
},
by_package: byPackage,
}],
@@ -13762,7 +13771,7 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server {
body.adcp_version = servedAdcpVersion;
if (callerContext !== undefined) body.context = callerContext;
toolResult = {
- content: [{ type: 'text', text: JSON.stringify(body) }],
+ content: [{ type: 'text', text: `${name} replay completed successfully.` }],
structuredContent: body,
};
cachableResponse = { ...(outcome.response as Record) };
@@ -13904,7 +13913,10 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server {
body.adcp_version = servedAdcpVersion;
if (callerContext !== undefined) body.context = callerContext;
toolResult = {
- content: [{ type: 'text', text: JSON.stringify(body) }],
+ content: [{
+ type: 'text',
+ text: `${name} completed with ${resultObj.errors!.length} reported error${resultObj.errors!.length === 1 ? '' : 's'}.`,
+ }],
structuredContent: body,
};
} else {
diff --git a/server/src/training-agent/tenants/custom-tool-helper.ts b/server/src/training-agent/tenants/custom-tool-helper.ts
index bee2df1adb..222d2232a7 100644
--- a/server/src/training-agent/tenants/custom-tool-helper.ts
+++ b/server/src/training-agent/tenants/custom-tool-helper.ts
@@ -58,7 +58,12 @@ interface IdempotencyClaim {
claimToken: string;
}
-function toAdaptedResponse(result: unknown, callerContext: unknown, options: CustomToolOptions): AdaptedResponse {
+function toAdaptedResponse(
+ toolName: string,
+ result: unknown,
+ callerContext: unknown,
+ options: CustomToolOptions,
+): AdaptedResponse {
const errsField = (result as { errors?: unknown[] } | null | undefined)?.errors;
const servedAdcpVersion = typeof (result as { adcp_version?: unknown } | null | undefined)?.adcp_version === 'string'
? (result as { adcp_version: string }).adcp_version
@@ -88,7 +93,10 @@ function toAdaptedResponse(result: unknown, callerContext: unknown, options: Cus
});
const response = withEnvelope as Record;
return {
- content: [{ type: 'text', text: options.responseSummary?.(inner) ?? JSON.stringify(response) }],
+ content: [{
+ type: 'text',
+ text: options.responseSummary?.(inner) ?? `${toolName} completed.`,
+ }],
structuredContent: response,
};
}
@@ -249,7 +257,7 @@ export function customToolFor(
if (outcome.kind === 'replay') {
const replayed: Record = { ...(outcome.response as Record), replayed: true };
if (replayed.status === undefined) replayed.status = 'completed';
- return toAdaptedResponse(replayed, callerContext, options);
+ return toAdaptedResponse(name, replayed, callerContext, options);
}
claim = {
principal,
@@ -273,7 +281,7 @@ export function customToolFor(
logger.error({ err, tool: name }, 'custom-tool flushDirtySessions threw');
return serviceUnavailable(err, callerContext);
}
- const response = toAdaptedResponse(result, callerContext, options);
+ const response = toAdaptedResponse(name, result, callerContext, options);
if (claim) {
const hasPayloadErrors = Array.isArray((result as { errors?: unknown[] } | null | undefined)?.errors)
&& ((result as { errors?: unknown[] }).errors ?? []).length > 0;
diff --git a/server/src/training-agent/tenants/router.ts b/server/src/training-agent/tenants/router.ts
index 2256bf987f..31eb3e72c8 100644
--- a/server/src/training-agent/tenants/router.ts
+++ b/server/src/training-agent/tenants/router.ts
@@ -605,7 +605,10 @@ async function tryHandleLocalComplyScenario(
jsonrpc: '2.0',
id: req.body.id ?? null,
result: {
- content: [{ type: 'text', text: JSON.stringify(structuredContent) }],
+ content: [{
+ type: 'text',
+ text: `Compliance scenario ${String(rawArgs.scenario)} completed.`,
+ }],
structuredContent,
},
});
@@ -916,7 +919,7 @@ function projectTenantCapabilities(
projectWholesaleCapabilities(structured, tenantId, storyboardCompat);
const firstText = parsed.result?.content?.[0];
if (firstText?.type === 'text') {
- firstText.text = JSON.stringify(structured);
+ firstText.text = 'Capabilities retrieved successfully.';
}
return Buffer.from(JSON.stringify(parsed), 'utf8');
} catch {
diff --git a/server/src/training-agent/tenants/sales.ts b/server/src/training-agent/tenants/sales.ts
index 8d8f1d48e0..f4baef2096 100644
--- a/server/src/training-agent/tenants/sales.ts
+++ b/server/src/training-agent/tenants/sales.ts
@@ -1,5 +1,5 @@
/**
- * /sales tenant — sales-non-guaranteed + sales-guaranteed specialisms.
+ * /sales tenant — non-guaranteed, guaranteed, and DOOH sales specialisms.
*
* Distinct platform from /signals (single-specialism per tenant). Buyers
* call sales-track tools at this URL; signals tools live on /signals.
diff --git a/server/src/training-agent/tenants/tenant-smoke.test.ts b/server/src/training-agent/tenants/tenant-smoke.test.ts
index 24bc417aba..63747f343d 100644
--- a/server/src/training-agent/tenants/tenant-smoke.test.ts
+++ b/server/src/training-agent/tenants/tenant-smoke.test.ts
@@ -430,6 +430,25 @@ describe('tenant routing smoke', () => {
}
}, 30000);
+ it('advertises the executable DOOH sales profile and channel', async () => {
+ const { baseUrl, close } = await bootServer();
+ try {
+ const url = `${baseUrl}/sales/mcp`;
+ await initializeTenant(url);
+ const response = await callTenantTool(url, 31, 'get_adcp_capabilities', {}) as {
+ result?: { structuredContent?: {
+ specialisms?: string[];
+ media_buy?: { portfolio?: { primary_channels?: string[] } };
+ } };
+ };
+ const capabilities = response.result?.structuredContent;
+ expect(capabilities?.specialisms).toContain('sales-dooh');
+ expect(capabilities?.media_buy?.portfolio?.primary_channels).toContain('dooh');
+ } finally {
+ await close();
+ }
+ }, 30000);
+
it('rejects a governed rights acquisition without persisting a grant', async () => {
const { baseUrl, close } = await bootServer();
try {
@@ -652,6 +671,7 @@ describe('tenant routing smoke', () => {
});
const body = await r.json() as {
result?: {
+ content?: Array<{ type?: string; text?: string }>;
structuredContent?: {
adcp_version?: string;
adcp?: { major_versions?: number[]; supported_versions?: string[] };
@@ -674,6 +694,9 @@ describe('tenant routing smoke', () => {
expect(body.result?.structuredContent?.compliance_testing?.scenarios).toEqual(
expect.arrayContaining(SALES_CURRENT_SCENARIOS),
);
+ expect(body.result?.content?.[0]?.text).toBe('Capabilities retrieved successfully.');
+ expect(body.result?.content?.[0]?.text)
+ .not.toBe(JSON.stringify(body.result?.structuredContent));
} finally {
await close();
}
@@ -2281,12 +2304,18 @@ describe('tenant routing smoke', () => {
result?: { structuredContent?: { products?: unknown[]; replayed?: boolean } };
};
const replay = await callTenantTool(url, 4, 'get_products', payload) as {
- result?: { structuredContent?: { products?: unknown[]; replayed?: boolean } };
+ result?: {
+ content?: Array<{ type?: string; text?: string }>;
+ structuredContent?: { products?: unknown[]; replayed?: boolean };
+ };
};
expect(first.result?.structuredContent?.products?.length).toBeGreaterThan(0);
expect(first.result?.structuredContent?.replayed).toBeUndefined();
expect(replay.result?.structuredContent?.products).toEqual(first.result?.structuredContent?.products);
expect(replay.result?.structuredContent?.replayed).toBe(true);
+ expect(replay.result?.content?.[0]?.text).toMatch(/products|completed/i);
+ expect(replay.result?.content?.[0]?.text)
+ .not.toBe(JSON.stringify(replay.result?.structuredContent));
const changed = await callTenantTool(url, 5, 'get_products', {
buying_mode: 'brief',
@@ -2310,15 +2339,38 @@ describe('tenant routing smoke', () => {
operator: 'tenant-products-advisory.example',
sandbox: true,
};
- const directive = await callTenantTool(url, 2, 'comply_test_controller', {
+ const scenarioList = await callTenantTool(url, 2, 'comply_test_controller', {
+ scenario: 'list_scenarios',
+ }) as {
+ result?: {
+ content?: Array<{ type?: string; text?: string }>;
+ structuredContent?: { success?: boolean; scenarios?: unknown[] };
+ };
+ };
+ expect(scenarioList.result?.structuredContent?.success).toBe(true);
+ expect(scenarioList.result?.structuredContent?.scenarios?.length).toBeGreaterThan(0);
+ expect(scenarioList.result?.content?.[0]?.text)
+ .toBe('Compliance scenario list_scenarios completed.');
+ expect(scenarioList.result?.content?.[0]?.text)
+ .not.toBe(JSON.stringify(scenarioList.result?.structuredContent));
+
+ const directive = await callTenantTool(url, 3, 'comply_test_controller', {
account,
scenario: 'force_upstream_unavailable',
params: { tool: 'get_products', upstream_name: 'catalog-test' },
- }) as { result?: { structuredContent?: { success?: boolean } } };
+ }) as {
+ result?: {
+ content?: Array<{ type?: string; text?: string }>;
+ structuredContent?: { success?: boolean };
+ };
+ };
expect(directive.result?.structuredContent?.success).toBe(true);
+ expect(directive.result?.content?.[0]?.text).toMatch(/scenario|completed/i);
+ expect(directive.result?.content?.[0]?.text)
+ .not.toBe(JSON.stringify(directive.result?.structuredContent));
const key = 'tenant-products-advisory-replay-0001';
- const first = await callTenantTool(url, 3, 'get_products', {
+ const first = await callTenantTool(url, 4, 'get_products', {
idempotency_key: key,
buying_mode: 'wholesale',
account,
@@ -2326,7 +2378,7 @@ describe('tenant routing smoke', () => {
}) as {
result?: { structuredContent?: { products?: unknown[]; errors?: Array<{ code?: string }>; context?: { correlation_id?: string } } };
};
- const replay = await callTenantTool(url, 4, 'get_products', {
+ const replay = await callTenantTool(url, 5, 'get_products', {
idempotency_key: key,
buying_mode: 'wholesale',
account,
diff --git a/server/src/training-agent/types.ts b/server/src/training-agent/types.ts
index daee76cb2a..c4f4c67dcd 100644
--- a/server/src/training-agent/types.ts
+++ b/server/src/training-agent/types.ts
@@ -249,6 +249,10 @@ export interface RightsGrantState {
export interface ComplyDeliveryAccumulator {
impressions: number;
clicks: number;
+ /** Raw DOOH/broadcast plays injected by simulate_delivery. */
+ plays?: number;
+ /** Latest DOOH delivery detail block injected by simulate_delivery. */
+ doohMetrics?: Record;
reportedSpend: { amount: number; currency: string };
conversions: number;
conversionValue?: number;
diff --git a/server/src/training-agent/v6-sales-platform.ts b/server/src/training-agent/v6-sales-platform.ts
index 1797aabe90..74e03ae497 100644
--- a/server/src/training-agent/v6-sales-platform.ts
+++ b/server/src/training-agent/v6-sales-platform.ts
@@ -1,8 +1,8 @@
/**
* v6 SalesPlatform for the `/sales` tenant.
*
- * Single-specialism platform claiming `sales-non-guaranteed` +
- * `sales-guaranteed`. Implements `SalesPlatform` (5 required methods +
+ * Sales platform claiming `sales-non-guaranteed`, `sales-guaranteed`, and
+ * `sales-dooh`. Implements `SalesPlatform` (5 required methods +
* 4 optional read-side methods).
*
* Spike-grade port: bodies shim through to existing v5 handlers via
@@ -47,6 +47,7 @@ import {
import { handleSyncAudiences } from './audience-handlers.js';
import { syncAccountsUpsert } from './v6-account-helpers.js';
import { trainingBuyerAgentRegistry } from './buyer-agent-registry.js';
+import { PUBLISHERS } from './publishers.js';
import { waitForForcedTaskCompletion } from './comply-test-controller.js';
import { sessionKeyFromArgs } from './state.js';
import type { ToolArgs, TrainingContext } from './types.js';
@@ -105,10 +106,42 @@ export function restoreRawPackageSelectors(
return restored;
}
+// The in-repo schema adds this value before the published SDK's generated
+// AdCPSpecialism union can include it. Keep the cast at this single boundary;
+// the wire value remains the literal `sales-dooh` and is schema-tested here.
+const SALES_DOOH_SPECIALISM = 'sales-dooh' as never;
+
+const TRAINING_SALES_CHANNELS = [
+ 'display',
+ 'olv',
+ 'ctv',
+ 'email',
+ 'streaming_audio',
+ 'podcast',
+ 'dooh',
+ 'ooh',
+ 'gaming',
+ 'retail_media',
+ 'linear_tv',
+ 'social',
+ 'influencer',
+ 'search',
+ 'radio',
+ 'print',
+] as const;
+
export const TRAINING_SALES_CAPABILITIES = {
- specialisms: ['sales-non-guaranteed', 'sales-guaranteed'] as const,
+ specialisms: ['sales-non-guaranteed', 'sales-guaranteed', SALES_DOOH_SPECIALISM] as const,
creative_agents: [],
- channels: [] as const,
+ channels: TRAINING_SALES_CHANNELS,
+ overrides: {
+ media_buy: {
+ portfolio: {
+ publisher_domains: PUBLISHERS.map(publisher => publisher.domain),
+ primary_channels: [...TRAINING_SALES_CHANNELS],
+ },
+ },
+ },
pricingModels: ['cpm', 'cpa'] as const,
targeting: {
geo_countries: true,
diff --git a/server/tests/unit/comply-test-controller.test.ts b/server/tests/unit/comply-test-controller.test.ts
index 7e8635ea68..1f6dd085bf 100644
--- a/server/tests/unit/comply-test-controller.test.ts
+++ b/server/tests/unit/comply-test-controller.test.ts
@@ -60,7 +60,10 @@ async function simulateListTools(server: ReturnType): Promise {
+async function createMediaBuy(
+ server: ReturnType,
+ packageCount = 1,
+): Promise {
// Get a valid product first
const { result: products, isError: productsError } = await simulateCallTool(server, 'get_products', {
buying_mode: 'wholesale',
@@ -85,11 +88,11 @@ async function createMediaBuy(server: ReturnType ({
product_id: product.product_id,
pricing_option_id: pricingOption.pricing_option_id,
budget: 10000,
- }],
+ })),
});
if (isError || (result as any).errors) {
throw new Error(`create_media_buy failed: ${JSON.stringify(result)}`);
@@ -1755,6 +1758,12 @@ describe('comply_test_controller', () => {
media_buy_id: mediaBuyId,
impressions: 10000,
clicks: 150,
+ plays: 240,
+ dooh_metrics: {
+ loop_plays: 240,
+ screens_used: 12,
+ screen_time_seconds: 1440,
+ },
reported_spend: { amount: 150.00, currency: 'USD' },
},
account: ACCOUNT,
@@ -1762,7 +1771,13 @@ describe('comply_test_controller', () => {
});
expect(simResult.success).toBe(true);
expect((simResult as any).simulated.impressions).toBe(10000);
+ expect((simResult as any).simulated.plays).toBe(240);
expect((simResult as any).cumulative.impressions).toBe(10000);
+ expect((simResult as any).cumulative.dooh_metrics).toEqual({
+ loop_plays: 240,
+ screens_used: 12,
+ screen_time_seconds: 1440,
+ });
// Verify reflected in delivery
const { result: delivery } = await simulateCallTool(server, 'get_media_buy_delivery', {
@@ -1773,6 +1788,15 @@ describe('comply_test_controller', () => {
const totals = (delivery as any).media_buy_deliveries[0].totals;
expect(totals.impressions).toBeGreaterThanOrEqual(10000);
expect(totals.clicks).toBeGreaterThanOrEqual(150);
+ expect(totals.plays).toBe(240);
+ expect(totals.dooh_metrics).toEqual({
+ loop_plays: 240,
+ screens_used: 12,
+ screen_time_seconds: 1440,
+ });
+ const packageDelivery = (delivery as any).media_buy_deliveries[0].by_package[0];
+ expect(packageDelivery.plays).toBe(240);
+ expect(packageDelivery.dooh_metrics.screens_used).toBe(12);
});
it('is additive across calls', async () => {
@@ -1780,18 +1804,51 @@ describe('comply_test_controller', () => {
await simulateCallTool(server, 'comply_test_controller', {
scenario: 'simulate_delivery',
- params: { media_buy_id: mediaBuyId, impressions: 5000 },
+ params: {
+ media_buy_id: mediaBuyId,
+ impressions: 5000,
+ plays: 40,
+ dooh_metrics: { loop_plays: 40, screens_used: 8 },
+ },
account: ACCOUNT,
brand: BRAND,
});
const { result } = await simulateCallTool(server, 'comply_test_controller', {
scenario: 'simulate_delivery',
- params: { media_buy_id: mediaBuyId, impressions: 3000 },
+ params: {
+ media_buy_id: mediaBuyId,
+ impressions: 3000,
+ plays: 35,
+ dooh_metrics: { loop_plays: 35, screens_used: 6 },
+ },
account: ACCOUNT,
brand: BRAND,
});
expect((result as any).cumulative.impressions).toBe(8000);
+ expect((result as any).cumulative.plays).toBe(75);
+ expect((result as any).cumulative.dooh_metrics).toEqual({ loop_plays: 35, screens_used: 6 });
+ });
+
+ it('rejects media-buy-scoped DOOH metrics for multi-package buys without mutating delivery state', async () => {
+ const mediaBuyId = await createMediaBuy(server, 2);
+
+ const { result } = await simulateCallTool(server, 'comply_test_controller', {
+ scenario: 'simulate_delivery',
+ params: {
+ media_buy_id: mediaBuyId,
+ plays: 25,
+ dooh_metrics: { loop_plays: 25, screens_used: 4 },
+ },
+ account: ACCOUNT,
+ brand: BRAND,
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('INVALID_PARAMS');
+ const sessionKey = sessionKeyFromArgs({ account: ACCOUNT }, DEFAULT_CTX.mode, DEFAULT_CTX.userId, DEFAULT_CTX.moduleId);
+ const session = await getSession(sessionKey);
+ expect(session.complyExtensions.deliverySimulations.has(mediaBuyId)).toBe(false);
});
it('filters dated delivery batches with start-inclusive, end-exclusive boundaries', async () => {
diff --git a/server/tests/unit/creative-agent.test.ts b/server/tests/unit/creative-agent.test.ts
index d2cf14a5c1..78523f225e 100644
--- a/server/tests/unit/creative-agent.test.ts
+++ b/server/tests/unit/creative-agent.test.ts
@@ -1421,7 +1421,7 @@ describe('MCP tool responses include structuredContent', () => {
expect(structured.formats.length).toBe(4);
});
- it('list_creative_formats structuredContent matches content text', async () => {
+ it('list_creative_formats keeps the text summary distinct from structuredContent', async () => {
const result = await client.callTool({
name: 'list_creative_formats',
arguments: {},
@@ -1430,7 +1430,8 @@ describe('MCP tool responses include structuredContent', () => {
const structured = result.structuredContent as Record;
const content = result.content as Array<{ type: string; text: string }>;
expect(content).toHaveLength(1);
- expect(JSON.parse(content[0].text)).toEqual(structured);
+ expect(content[0].text).toBe('list_creative_formats completed successfully.');
+ expect(content[0].text).not.toBe(JSON.stringify(structured));
});
it('preview_creative returns structuredContent with previews', async () => {
@@ -1456,7 +1457,7 @@ describe('MCP tool responses include structuredContent', () => {
expect(structured.previews.length).toBe(1);
});
- it('preview_creative structuredContent matches content text', async () => {
+ it('preview_creative returns a terse text summary beside structuredContent', async () => {
const result = await client.callTool({
name: 'preview_creative',
arguments: {
@@ -1469,7 +1470,24 @@ describe('MCP tool responses include structuredContent', () => {
const structured = result.structuredContent as Record;
const content = result.content as Array<{ type: string; text: string }>;
- expect(JSON.parse(content[0].text)).toEqual(structured);
+ expect(content[0].text).toBe('preview_creative completed successfully.');
+ expect(content[0].text).not.toBe(JSON.stringify(structured));
+ });
+
+ it('preview_creative summary does not claim success when the payload reports errors', async () => {
+ const result = await client.callTool({
+ name: 'preview_creative',
+ arguments: {
+ request_type: 'variant',
+ creative_id: 'creative-without-delivery-state',
+ },
+ });
+
+ const structured = result.structuredContent as { errors?: unknown[] };
+ const content = result.content as Array<{ type: string; text: string }>;
+ expect(structured.errors).toHaveLength(1);
+ expect(content[0].text).toBe('preview_creative completed with 1 reported error.');
+ expect(content[0].text).not.toMatch(/success/i);
});
it('list_creative_formats structuredContent includes all 76 reference and UI formats', async () => {
diff --git a/server/tests/unit/specialism-status.test.ts b/server/tests/unit/specialism-status.test.ts
index a7e6a87c36..932b53aa1d 100644
--- a/server/tests/unit/specialism-status.test.ts
+++ b/server/tests/unit/specialism-status.test.ts
@@ -2,6 +2,14 @@ import { describe, it, expect } from 'vitest';
import { computeSpecialismStatus } from '../../src/addie/services/compliance-testing.js';
describe('computeSpecialismStatus', () => {
+ it('maps the sales-dooh claim to the sales_dooh storyboard', () => {
+ const result = computeSpecialismStatus(
+ ['sales-dooh'],
+ [{ storyboard_id: 'sales_dooh', status: 'passing', steps_passed: 5, steps_total: 5 }],
+ );
+ expect(result).toEqual({ 'sales-dooh': 'passing' });
+ });
+
it('returns passing for specialisms whose storyboard passed', () => {
const result = computeSpecialismStatus(
['sales-broadcast-tv'],
diff --git a/server/tests/unit/training-agent-account-scope.test.ts b/server/tests/unit/training-agent-account-scope.test.ts
index efd17eafab..a2b9d75b09 100644
--- a/server/tests/unit/training-agent-account-scope.test.ts
+++ b/server/tests/unit/training-agent-account-scope.test.ts
@@ -177,6 +177,22 @@ describe('canonical session scope', () => {
});
describe('custom-tool account scoping', () => {
+ it('keeps successful payloads in structuredContent and emits only a terse text summary', async () => {
+ const tool = customToolFor(
+ 'test_read',
+ 'test',
+ z.any(),
+ () => ({ records: [{ id: 'record-1', value: 'full payload' }] }),
+ );
+ const response = await (tool.handler as any)({}, {});
+
+ expect(response.structuredContent.records).toEqual([{ id: 'record-1', value: 'full payload' }]);
+ expect(response.content).toEqual([
+ { type: 'text', text: 'test_read completed.' },
+ ]);
+ expect(response.content[0].text).not.toBe(JSON.stringify(response.structuredContent));
+ });
+
it('reuses the shared canonical scope for top-level and usage accounts', () => {
const account = {
brand: { domain: 'House.Example', brand_id: 'spark' },
diff --git a/static/compliance/source/specialisms/sales-dooh/index.yaml b/static/compliance/source/specialisms/sales-dooh/index.yaml
new file mode 100644
index 0000000000..33ca25e59c
--- /dev/null
+++ b/static/compliance/source/specialisms/sales-dooh/index.yaml
@@ -0,0 +1,611 @@
+id: sales_dooh
+version: "1.0.0"
+title: "Digital out-of-home — non-guaranteed"
+protocol: media-buy
+category: sales_dooh
+summary: "DOOH seller for non-guaranteed venue and screen inventory with canonical screen formats and substantive play reporting."
+track: media_buy
+required_tools:
+ - sync_governance
+ - sync_creatives
+ - list_products
+ - create_media_buy
+ - get_media_buy_delivery
+requires_scenarios:
+ - media_buy_seller/delivery_reporting
+ - media_buy_seller/pending_creatives_to_start
+ - media_buy_seller/invalid_transitions
+ - governance_aware_seller/governance_multi_agent_rejected
+
+invariants:
+ - status.monotonic
+
+context:
+ governance_agent_url: "https://test-agent.adcontextprotocol.org"
+
+narrative: |
+ You run a digital out-of-home seller that offers venue and screen inventory
+ through a non-guaranteed buying path. A buyer discovers DOOH products, checks
+ the exact dimensions and file constraints accepted by the screens, syncs a
+ compatible creative, creates a buy, and
+ reconciles delivery from screen logs.
+
+ DOOH products are still ordinary AdCP products. The `dooh` channel identifies
+ them; publisher properties and public placements identify venue or screen
+ scope; canonical `format_options` express the screen's creative contract.
+ Delivery reports use `plays` and `dooh_metrics` for screen activity. Optional
+ attention measures use the existing vendor-metric contracts and their own
+ selected conformance profile rather than becoming a requirement of the core
+ DOOH claim.
+
+ This storyboard intentionally does not require click, conversion, browser-pixel,
+ or persistent-person identifiers. Those are not inherent to DOOH delivery.
+
+agent:
+ interaction_model: media_buy_seller
+ capabilities:
+ - sells_media
+ - accepts_briefs
+ - supports_non_guaranteed
+ - sells_dooh
+ examples:
+ - "Non-guaranteed transit screen network seller"
+ - "Non-guaranteed venue media marketplace"
+ - "Auction-based digital billboard marketplace"
+
+caller:
+ role: buyer_agent
+ example: "Pinnacle Agency buyer"
+
+prerequisites:
+ description: |
+ The caller needs a sandbox account and a creative suitable for a portrait
+ screen. The test kit provides the fictional Acme Outdoor brand and assets.
+ test_kit: "test-kits/acme-outdoor.yaml"
+ controller_seeding: true
+
+fixtures:
+ products:
+ - product_id: "dooh_transit_concourse_loop"
+ name: "Transit concourse portrait screens"
+ description: "A non-guaranteed package of portrait screens in fictional transit concourses."
+ publisher_properties:
+ - publisher_domain: "metro-media.example"
+ selection_type: "by_tag"
+ property_tags: ["transit_venues"]
+ channels: ["dooh"]
+ delivery_type: "non_guaranteed"
+ format_options:
+ - format_option_id: "dooh_portrait_image_1080x1920"
+ format_kind: "image"
+ params:
+ width: 1080
+ height: 1920
+ max_file_size_kb: 10240
+ image_formats: ["jpg", "png"]
+ ssl_required: true
+ asset_source: "buyer_uploaded"
+ buyer_asset_acceptance: "accepted"
+ composition_model: "deterministic"
+ slots:
+ - asset_group_id: "image_main"
+ asset_type: "image"
+ required: true
+ placements:
+ - kind: "seller_inline"
+ publisher_domain: "metro-media.example"
+ placement_id: "central_concourse_portrait_screens"
+ name: "Central concourse portrait screens"
+ mode: "included"
+ tags: ["transit", "concourse", "portrait"]
+ identifiers:
+ - type: "venue_id"
+ value: "metro:central-concourse"
+ dooh_placement_attributes:
+ slot_duration_seconds: 10
+ loop_duration_seconds: 80
+ screen_resolution:
+ width: 1080
+ height: 1920
+ motion: "full_motion"
+ reporting_capabilities:
+ available_reporting_frequencies: ["daily"]
+ expected_delay_minutes: 240
+ timezone: "UTC"
+ supports_webhooks: false
+ available_metrics: ["impressions", "spend", "plays", "dooh_metrics"]
+ supports_placement_breakdown: true
+ date_range_support: "date_range"
+ pricing_options:
+ - product_id: "dooh_transit_concourse_loop"
+ pricing_option_id: "dooh_transit_cpm_auction"
+ pricing_model: "cpm"
+ currency: "USD"
+ floor_price: 8.0
+ price_guidance:
+ p50: 10.0
+ p75: 12.0
+
+phases:
+ - id: capability_discovery
+ title: "Discover the DOOH seller"
+ narrative: |
+ The buyer verifies both the executable DOOH specialization and the
+ portfolio routing declaration. The specialism selects this storyboard;
+ primary_channels tells buyers which briefs the seller accepts.
+ steps:
+ - id: get_capabilities
+ title: "Check DOOH capabilities"
+ task: get_adcp_capabilities
+ schema_ref: "protocol/get-adcp-capabilities-request.json"
+ response_schema_ref: "protocol/get-adcp-capabilities-response.json"
+ doc_ref: "/protocol/get_adcp_capabilities"
+ comply_scenario: capability_discovery
+ stateful: false
+ expected: |
+ Declare the media-buy protocol, the sales-dooh specialism, and dooh
+ in media_buy.portfolio.primary_channels.
+ sample_request:
+ context:
+ correlation_id: "sales_dooh--get_capabilities"
+ validations:
+ - check: response_schema
+ description: "Response matches get-adcp-capabilities-response.json schema"
+ - check: field_contains
+ path: "supported_protocols[*]"
+ value: "media_buy"
+ description: "Agent declares media_buy protocol support"
+ - check: field_contains
+ path: "specialisms[*]"
+ value: "sales-dooh"
+ description: "Agent declares the sales-dooh specialism"
+ - check: field_contains
+ path: "media_buy.portfolio.primary_channels[*]"
+ value: "dooh"
+ description: "Portfolio routing scope includes DOOH"
+ - check: field_value
+ path: "context.correlation_id"
+ value: "sales_dooh--get_capabilities"
+ description: "Context correlation_id returned unchanged"
+
+ - id: product_discovery
+ title: "Discover venue and screen inventory"
+ narrative: |
+ The buyer reads an exact seeded DOOH product from the 3.2 product list.
+ The returned product must be independently actionable: placement attributes
+ describe the physical screen and loop, while its canonical format declaration
+ independently supplies accepted asset constraints without a legacy
+ format-catalog lookup.
+ steps:
+ - id: get_dooh_products
+ title: "Read non-guaranteed DOOH products"
+ task: list_products
+ schema_ref: "media-buy/list-products-request.json"
+ response_schema_ref: "media-buy/list-products-response.json"
+ doc_ref: "/media-buy/task-reference/list_products"
+ comply_scenario: full_sales_flow
+ stateful: true
+ expected: |
+ Return the seeded DOOH product with:
+ - channels containing dooh
+ - delivery_type set to non_guaranteed
+ - canonical format_options with concrete screen dimensions
+ - publisher property and placement scope with DOOH inventory facts
+ - plays and dooh_metrics reporting
+ sample_request:
+ idempotency_key: "$generate:uuid_v4#sales_dooh_product_discovery_get_dooh_products"
+ criteria:
+ product_ids: ["dooh_transit_concourse_loop"]
+ account:
+ brand:
+ domain: "acmeoutdoor.example"
+ operator: "pinnacle-agency.example"
+ sandbox: true
+ context:
+ correlation_id: "sales_dooh--get_dooh_products"
+ context_outputs:
+ - path: "products[0].product_id"
+ key: "dooh_product_id"
+ validations:
+ - check: response_schema
+ description: "Response matches list-products-response.json schema"
+ - check: field_value
+ path: "products[0].product_id"
+ value: "dooh_transit_concourse_loop"
+ description: "Exact fixture selection makes discovery independent of catalog order"
+ - check: field_contains
+ path: "products[0].channels[*]"
+ value: "dooh"
+ description: "Returned product is sold as DOOH"
+ - check: field_value
+ path: "products[0].delivery_type"
+ value: "non_guaranteed"
+ description: "Returned product uses the non-guaranteed path"
+ - check: field_value
+ path: "products[0].format_options[0].format_kind"
+ value: "image"
+ description: "Canonical image declaration remains the creative-acceptance authority"
+ - check: field_value
+ path: "products[0].format_options[0].params.width"
+ value: 1080
+ description: "Canonical format declares accepted creative width"
+ - check: field_value
+ path: "products[0].format_options[0].params.height"
+ value: 1920
+ description: "Canonical format declares accepted creative height"
+ - check: field_present
+ path: "products[0].publisher_properties[0].publisher_domain"
+ description: "Product identifies its publisher property scope"
+ - check: field_present
+ path: "products[0].placements[0].placement_id"
+ description: "Product identifies its public screen placement"
+ - check: field_value
+ path: "products[0].placements[0].identifiers[0].type"
+ value: "venue_id"
+ description: "Placement exposes an optional public venue identifier"
+ - check: field_value
+ path: "products[0].placements[0].dooh_placement_attributes.slot_duration_seconds"
+ value: 10
+ description: "Placement declares the scheduled slot duration"
+ - check: field_value
+ path: "products[0].placements[0].dooh_placement_attributes.loop_duration_seconds"
+ value: 80
+ description: "Placement declares the canonical loop duration"
+ - check: field_value
+ path: "products[0].placements[0].dooh_placement_attributes.screen_resolution.width"
+ value: 1080
+ description: "Placement declares physical screen width"
+ - check: field_value
+ path: "products[0].placements[0].dooh_placement_attributes.screen_resolution.height"
+ value: 1920
+ description: "Placement declares physical screen height"
+ - check: field_value
+ path: "products[0].placements[0].dooh_placement_attributes.motion"
+ value: "full_motion"
+ description: "Physical motion capability remains distinct from the image-only format contract"
+ - check: field_contains
+ path: "products[0].reporting_capabilities.available_metrics[*]"
+ value: "plays"
+ description: "Product can report raw screen plays"
+ - check: field_contains
+ path: "products[0].reporting_capabilities.available_metrics[*]"
+ value: "dooh_metrics"
+ description: "Product can report DOOH delivery detail"
+ - check: field_value
+ path: "context.correlation_id"
+ value: "sales_dooh--get_dooh_products"
+ description: "Context correlation_id returned unchanged"
+
+ - id: creative_sync
+ title: "Sync a screen-compatible creative"
+ narrative: |
+ The buyer uploads a hosted portrait image that satisfies the exact
+ canonical dimensions returned by product discovery. DOOH creative is a
+ file delivered to a screen runtime; browser trackers and click assets are
+ not required by this scenario.
+ steps:
+ - id: validate_input
+ title: "Validate the portrait asset against the product"
+ task: validate_input
+ requires_tool: validate_input
+ schema_ref: "creative/validate-input-request.json"
+ response_schema_ref: "creative/validate-input-response.json"
+ doc_ref: "/creative/canonical-formats#validation-flow--validate_input"
+ comply_scenario: canonical_format_validation
+ stateful: true
+ expected: |
+ When the seller offers validate_input, validate the complete portrait
+ image manifest against the exact DOOH product returned by discovery
+ and return validated_pass.
+ sample_request:
+ account:
+ brand:
+ domain: "acmeoutdoor.example"
+ operator: "pinnacle-agency.example"
+ sandbox: true
+ manifest:
+ format_kind: "image"
+ format_option_ref:
+ scope: "product"
+ format_option_id: "dooh_portrait_image_1080x1920"
+ assets:
+ image_main:
+ asset_type: "image"
+ url: "https://test-assets.adcontextprotocol.org/acme-outdoor/hero-1080x1920.jpg"
+ width: 1080
+ height: 1920
+ mime_type: "image/jpeg"
+ targets:
+ - kind: "product"
+ id: "$context.dooh_product_id"
+ context:
+ correlation_id: "sales_dooh--validate_input"
+ validations:
+ - check: response_schema
+ description: "Response matches validate-input-response.json schema"
+ - check: field_value
+ path: "results[0].result_kind"
+ value: "validated_pass"
+ description: "Portrait manifest satisfies the discovered DOOH product contract"
+ - check: field_value
+ path: "results[0].target.kind"
+ value: "product"
+ description: "Validation result is scoped to a product target"
+ - check: field_equals_context
+ path: "results[0].target.id"
+ context_key: "dooh_product_id"
+ description: "Validation result identifies the discovered DOOH product"
+ - check: field_value
+ path: "context.correlation_id"
+ value: "sales_dooh--validate_input"
+ description: "Context correlation_id returned unchanged"
+
+ - id: sync_creatives
+ title: "Push the portrait screen asset"
+ task: sync_creatives
+ schema_ref: "creative/sync-creatives-request.json"
+ response_schema_ref: "creative/sync-creatives-response.json"
+ doc_ref: "/creative/task-reference/sync_creatives"
+ comply_scenario: creative_sync
+ stateful: true
+ expected: |
+ Accept the image creative for review or processing after validating
+ its 1080x1920 dimensions against the product's canonical format.
+ sample_request:
+ account:
+ brand:
+ domain: "acmeoutdoor.example"
+ operator: "pinnacle-agency.example"
+ sandbox: true
+ creatives:
+ - creative_id: "dooh_acme_portrait_1080x1920"
+ name: "Acme Outdoor portrait screen"
+ format_kind: "image"
+ assets:
+ image_main:
+ asset_type: "image"
+ url: "https://test-assets.adcontextprotocol.org/acme-outdoor/hero-1080x1920.jpg"
+ width: 1080
+ height: 1920
+ mime_type: "image/jpeg"
+ idempotency_key: "$generate:uuid_v4#sales_dooh_creative_sync_sync_creatives"
+ context:
+ correlation_id: "sales_dooh--sync_creatives"
+ validations:
+ - check: response_schema
+ description: "Response matches sync-creatives-response.json schema"
+ - check: field_value
+ path: "creatives[0].creative_id"
+ value: "dooh_acme_portrait_1080x1920"
+ description: "Response identifies the submitted DOOH creative"
+ - check: field_value
+ path: "creatives[0].action"
+ allowed_values: ["created", "updated", "unchanged"]
+ description: "Seller accepts the product-validated creative rather than returning a per-item failure"
+ - check: field_value
+ path: "context.correlation_id"
+ value: "sales_dooh--sync_creatives"
+ description: "Context correlation_id returned unchanged"
+
+ - id: create_buy
+ title: "Create the non-guaranteed DOOH buy"
+ narrative: |
+ The buyer registers governance and creates a non-guaranteed buy against
+ the exact product and pricing option returned by discovery.
+ steps:
+ - id: sync_governance
+ title: "Register governance agent"
+ task: sync_governance
+ schema_ref: "account/sync-governance-request.json"
+ response_schema_ref: "account/sync-governance-response.json"
+ doc_ref: "/accounts/tasks/sync_governance"
+ stateful: true
+ expected: "Acknowledge governance registration for the sandbox account."
+ sample_request:
+ accounts:
+ - account:
+ brand:
+ domain: "acmeoutdoor.example"
+ operator: "pinnacle-agency.example"
+ sandbox: true
+ governance_agents:
+ - url: "$context.governance_agent_url"
+ authentication:
+ schemes: ["Bearer"]
+ credentials: "gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+ idempotency_key: "$generate:uuid_v4#sales_dooh_create_buy_sync_governance"
+ context:
+ correlation_id: "sales_dooh--sync_governance"
+ validations:
+ - check: response_schema
+ description: "Response matches sync-governance-response.json schema"
+ - check: field_value
+ path: "accounts[0].status"
+ value: "synced"
+ description: "Governance agent is registered"
+ - check: field_value
+ path: "context.correlation_id"
+ value: "sales_dooh--sync_governance"
+ description: "Context correlation_id returned unchanged"
+
+ - id: create_media_buy
+ title: "Buy the DOOH product"
+ task: create_media_buy
+ schema_ref: "media-buy/create-media-buy-request.json"
+ response_schema_ref: "media-buy/create-media-buy-response.json"
+ doc_ref: "/media-buy/task-reference/create_media_buy"
+ comply_scenario: create_media_buy
+ stateful: true
+ expected: |
+ Create a media buy containing the selected non-guaranteed DOOH
+ package and return its seller-assigned identifiers.
+ sample_request:
+ brand:
+ domain: "acmeoutdoor.example"
+ account:
+ brand:
+ domain: "acmeoutdoor.example"
+ operator: "pinnacle-agency.example"
+ sandbox: true
+ start_time: "asap"
+ end_time: "2099-06-30T23:59:59Z"
+ packages:
+ - product_id: "$context.dooh_product_id"
+ budget: 10000
+ bid_price: 10
+ pricing_option_id: "dooh_transit_cpm_auction"
+ creative_assignments:
+ - creative_id: "dooh_acme_portrait_1080x1920"
+ idempotency_key: "$generate:uuid_v4#sales_dooh_create_buy_create_media_buy"
+ context:
+ correlation_id: "sales_dooh--create_media_buy"
+ context_outputs:
+ - name: media_buy_id
+ path: "media_buy_id"
+ - name: dooh_package_id
+ path: "packages[0].package_id"
+ validations:
+ - check: response_schema
+ description: "Response matches create-media-buy-response.json schema"
+ - check: field_present
+ path: "media_buy_id"
+ description: "Seller assigns a media_buy_id"
+ - check: field_present
+ path: "packages[0].package_id"
+ description: "Seller assigns a package_id"
+ - check: field_value
+ path: "context.correlation_id"
+ value: "sales_dooh--create_media_buy"
+ description: "Context correlation_id returned unchanged"
+
+ - id: delivery
+ title: "Reconcile DOOH delivery"
+ narrative: |
+ The controller injects deterministic DOOH screen activity, then the buyer
+ requests delivery for the buy. The response must reproduce the exact
+ `plays` and `dooh_metrics` values and reconcile them to the purchased package.
+ steps:
+ - id: simulate_delivery
+ title: "Inject deterministic DOOH delivery"
+ task: comply_test_controller
+ requires_tool: comply_test_controller
+ comply_scenario: deterministic_delivery
+ stateful: true
+ expected: |
+ Acknowledge the exact play count and screen-activity metrics injected
+ for this sandbox media buy.
+ sample_request:
+ account:
+ brand:
+ domain: "acmeoutdoor.example"
+ operator: "pinnacle-agency.example"
+ sandbox: true
+ scenario: "simulate_delivery"
+ params:
+ media_buy_id: "$context.media_buy_id"
+ plays: 125
+ dooh_metrics:
+ loop_plays: 125
+ screens_used: 18
+ screen_time_seconds: 1250
+ sov_achieved: 0.125
+ context:
+ correlation_id: "sales_dooh--simulate_delivery"
+ validations:
+ - check: field_value
+ path: "success"
+ allowed_values: [true]
+ description: "DOOH delivery simulation succeeds"
+ - check: field_value
+ path: "simulated.plays"
+ value: 125
+ description: "Controller acknowledges the injected play count"
+ - check: field_value
+ path: "simulated.dooh_metrics.loop_plays"
+ value: 125
+ description: "Controller acknowledges the injected DOOH detail"
+ - check: field_value
+ path: "context.correlation_id"
+ value: "sales_dooh--simulate_delivery"
+ description: "Context correlation_id returned unchanged"
+
+ - id: get_delivery
+ title: "Read DOOH delivery"
+ task: get_media_buy_delivery
+ schema_ref: "media-buy/get-media-buy-delivery-request.json"
+ response_schema_ref: "media-buy/get-media-buy-delivery-response.json"
+ doc_ref: "/media-buy/task-reference/get_media_buy_delivery"
+ comply_scenario: reporting_flow
+ stateful: true
+ expected: |
+ Return delivery data for the requested media buy. DOOH sellers report
+ the exact injected screen activity through plays and dooh_metrics.
+ sample_request:
+ account:
+ brand:
+ domain: "acmeoutdoor.example"
+ operator: "pinnacle-agency.example"
+ sandbox: true
+ media_buy_ids:
+ - "$context.media_buy_id"
+ include_package_daily_breakdown: true
+ context:
+ correlation_id: "sales_dooh--get_delivery"
+ validations:
+ - check: response_schema
+ description: "Response matches get-media-buy-delivery-response.json schema"
+ - check: field_present
+ path: "media_buy_deliveries"
+ description: "Response contains DOOH delivery data"
+ - check: field_equals_context
+ path: "media_buy_deliveries[0].media_buy_id"
+ context_key: "media_buy_id"
+ description: "Delivery belongs to the requested media buy"
+ - check: field_contains
+ path: "media_buy_deliveries[0].by_package[*].package_id"
+ value: "$context.dooh_package_id"
+ description: "Delivery includes the purchased DOOH package"
+ - check: field_value
+ path: "media_buy_deliveries[0].by_package[0].plays"
+ value: 125
+ description: "Package delivery reproduces the injected raw play count"
+ - check: field_value
+ path: "media_buy_deliveries[0].by_package[0].dooh_metrics.loop_plays"
+ value: 125
+ description: "Package delivery reproduces the injected loop play count"
+ - check: field_value
+ path: "media_buy_deliveries[0].by_package[0].dooh_metrics.screens_used"
+ value: 18
+ description: "Package delivery reproduces the injected unique screen count"
+ - check: field_value
+ path: "media_buy_deliveries[0].by_package[0].dooh_metrics.screen_time_seconds"
+ value: 1250
+ description: "Package delivery reproduces the injected screen time"
+ - check: field_value
+ path: "media_buy_deliveries[0].by_package[0].dooh_metrics.sov_achieved"
+ value: 0.125
+ description: "Package delivery reproduces the injected share of voice"
+ - check: field_value
+ path: "media_buy_deliveries[0].totals.plays"
+ value: 125
+ description: "Buy totals reproduce the injected raw play count"
+ - check: field_value
+ path: "media_buy_deliveries[0].totals.dooh_metrics.loop_plays"
+ value: 125
+ description: "Buy totals reproduce the injected loop play count"
+ - check: field_value
+ path: "media_buy_deliveries[0].totals.dooh_metrics.screens_used"
+ value: 18
+ description: "Buy totals reproduce the injected unique screen count"
+ - check: field_value
+ path: "media_buy_deliveries[0].totals.dooh_metrics.screen_time_seconds"
+ value: 1250
+ description: "Buy totals reproduce the injected screen time"
+ - check: field_value
+ path: "media_buy_deliveries[0].totals.dooh_metrics.sov_achieved"
+ value: 0.125
+ description: "Buy totals reproduce the injected share of voice"
+ - check: field_value
+ path: "context.correlation_id"
+ value: "sales_dooh--get_delivery"
+ description: "Context correlation_id returned unchanged"
diff --git a/static/openapi/registry.yaml b/static/openapi/registry.yaml
index 0f6eeb625f..05c5d31d8e 100644
--- a/static/openapi/registry.yaml
+++ b/static/openapi/registry.yaml
@@ -4004,6 +4004,7 @@ components:
- property-lists
- sales-broadcast-tv
- sales-catalog-driven
+ - sales-dooh
- sales-guaranteed
- sales-non-guaranteed
- sales-proposal-mode
diff --git a/static/schemas/source/compliance/comply-test-controller-request.json b/static/schemas/source/compliance/comply-test-controller-request.json
index 051edc939b..dcd6660ee1 100644
--- a/static/schemas/source/compliance/comply-test-controller-request.json
+++ b/static/schemas/source/compliance/comply-test-controller-request.json
@@ -917,6 +917,19 @@
"minimum": 0,
"description": "Clicks to simulate. Used by simulate_delivery."
},
+ "plays": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Raw DOOH or broadcast plays to add to delivery. Used by simulate_delivery for single-package buys; sellers MUST surface the cumulative value at totals.plays and by_package[0].plays in the next get_media_buy_delivery response. Multi-package simulations require a future package-scoped form and MUST reject this media-buy-scoped field."
+ },
+ "dooh_metrics": {
+ "allOf": [
+ {
+ "$ref": "/schemas/core/delivery-metrics.json#/properties/dooh_metrics"
+ }
+ ],
+ "description": "DOOH delivery detail to inject. Used by simulate_delivery for single-package buys; the latest injected block replaces the previous simulated DOOH detail and MUST surface at totals.dooh_metrics and by_package[0].dooh_metrics. Multi-package simulations require a future package-scoped form and MUST reject this media-buy-scoped field."
+ },
"conversions": {
"type": "integer",
"minimum": 0,
@@ -1312,6 +1325,12 @@
"delivery_date": "2026-02-05",
"impressions": 10000,
"clicks": 150,
+ "plays": 240,
+ "dooh_metrics": {
+ "loop_plays": 240,
+ "screens_used": 12,
+ "screen_time_seconds": 1440
+ },
"reported_spend": {
"amount": 150,
"currency": "USD"
diff --git a/static/schemas/source/compliance/comply-test-controller-response.json b/static/schemas/source/compliance/comply-test-controller-response.json
index 1bc89ca08b..949c0a9f4c 100644
--- a/static/schemas/source/compliance/comply-test-controller-response.json
+++ b/static/schemas/source/compliance/comply-test-controller-response.json
@@ -147,7 +147,7 @@
},
{
"title": "SimulationSuccess",
- "description": "A simulate_delivery, simulate_budget_spend, catalog_item_availability_probe, compact_product_lifecycle_probe, or compact_direct_buy_lifecycle_probe operation succeeded. For delivery: simulated contains the metrics injected by this call (impressions/clicks/reported_spend/conversions plus optional reach/frequency/reach_window/viewability values) and cumulative contains running totals or latest non-additive metric state. For budget: simulated contains spend_percentage/computed_spend/budget. For catalog availability: simulated reports seeded foreign identity, actual eligibility gates, processed expiry time, or delete/recreate generation rotation according to params.operation. For compact product lifecycle: simulated reports deterministic preparation through MediaBuy control/readback or strict post-deadline proposal expiry. For compact direct-buy lifecycle: simulated reports deterministic preparation.",
+ "description": "A simulate_delivery, simulate_budget_spend, catalog_item_availability_probe, compact_product_lifecycle_probe, or compact_direct_buy_lifecycle_probe operation succeeded. For delivery: simulated contains the metrics injected by this call (impressions/clicks/plays/reported_spend/conversions plus optional DOOH, reach, frequency, reach-window, and viewability values) and cumulative contains running totals or latest non-additive metric state. For budget: simulated contains spend_percentage/computed_spend/budget. For catalog availability: simulated reports seeded foreign identity, actual eligibility gates, processed expiry time, or delete/recreate generation rotation according to params.operation. For compact product lifecycle: simulated reports deterministic preparation through MediaBuy control/readback or strict post-deadline proposal expiry. For compact direct-buy lifecycle: simulated reports deterministic preparation.",
"type": "object",
"properties": {
"success": {
diff --git a/static/schemas/source/core/canonical-placement.json b/static/schemas/source/core/canonical-placement.json
index 3ccfd0fe98..c4a3660232 100644
--- a/static/schemas/source/core/canonical-placement.json
+++ b/static/schemas/source/core/canonical-placement.json
@@ -20,7 +20,70 @@
"video_placement_types": { "type": "array", "items": { "$ref": "/schemas/enums/video-placement-type.json" }, "minItems": 1, "uniqueItems": true },
"audio_distribution_types": { "type": "array", "items": { "$ref": "/schemas/enums/audio-distribution-type.json" }, "minItems": 1, "uniqueItems": true },
"sponsored_placement_types": { "type": "array", "items": { "$ref": "/schemas/enums/sponsored-placement-type.json" }, "minItems": 1, "uniqueItems": true },
- "social_placement_surfaces": { "type": "array", "items": { "$ref": "/schemas/enums/social-placement-surface.json" }, "minItems": 1, "uniqueItems": true }
+ "social_placement_surfaces": { "type": "array", "items": { "$ref": "/schemas/enums/social-placement-surface.json" }, "minItems": 1, "uniqueItems": true },
+ "identifiers": {
+ "type": "array",
+ "description": "Optional external inventory identifiers for this placement. Externally governed values should be authority-prefixed; seller-local values are scoped by the surrounding publisher namespace. For publisher_ref placements, the effective set is the union of publisher and product declarations, de-duplicated by exact (type, value).",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": { "$ref": "/schemas/enums/identifier-types.json" },
+ "value": { "type": "string" }
+ },
+ "required": ["type", "value"],
+ "additionalProperties": true
+ },
+ "uniqueItems": true,
+ "minItems": 1
+ },
+ "dooh_placement_attributes": {
+ "type": "object",
+ "description": "DOOH screen and scheduled-loop facts. These fields do not define creative acceptance, which is governed exclusively by effective canonical format_options. For publisher_ref placements, product slot and loop values override publisher defaults while repeated screen_resolution and motion values must equal the publisher facts.",
+ "properties": {
+ "slot_duration_seconds": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Scheduled duration of one ad slot in seconds; not the creative-duration contract."
+ },
+ "loop_duration_seconds": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Duration of the full ad loop rotation in seconds and the canonical source for loop duration."
+ },
+ "screen_resolution": {
+ "type": "object",
+ "description": "Physical screen resolution; canonical format dimensions remain authoritative for creative acceptance.",
+ "properties": {
+ "width": { "type": "integer", "minimum": 1 },
+ "height": { "type": "integer", "minimum": 1 }
+ },
+ "required": ["width", "height"],
+ "additionalProperties": false
+ },
+ "motion": {
+ "$ref": "/schemas/enums/dooh-motion-type.json",
+ "description": "Physical motion capability, not an accepted-format declaration."
+ }
+ },
+ "x-adcp-validation": {
+ "verifier_constraints": {
+ "slot_fits_loop": {
+ "left_path": "slot_duration_seconds",
+ "operator": "less_than_or_equal",
+ "right_path": "loop_duration_seconds",
+ "evaluate_after": "publisher_ref_resolution"
+ },
+ "publisher_ref_resolution": {
+ "override_fields": ["slot_duration_seconds", "loop_duration_seconds"],
+ "inherit_when_omitted": true,
+ "must_equal_fields": ["screen_resolution", "motion"],
+ "identifier_merge": "union_by_type_and_value"
+ }
+ },
+ "spec": "docs/media-buy/product-discovery/media-products.mdx#dooh-placement-attributes"
+ },
+ "additionalProperties": true
+ }
},
"required": ["kind", "placement_id", "mode"],
"allOf": [
diff --git a/static/schemas/source/core/placement-definition.json b/static/schemas/source/core/placement-definition.json
index 8e8032b0c4..e83f010176 100644
--- a/static/schemas/source/core/placement-definition.json
+++ b/static/schemas/source/core/placement-definition.json
@@ -139,6 +139,75 @@
"uniqueItems": true,
"minItems": 1
},
+ "identifiers": {
+ "type": "array",
+ "description": "Optional external inventory identifiers for this placement, using the same {type, value} shape as property identifiers. Externally governed IDs should be authority-prefixed (e.g., space:1234931339, geopath:30961, fcc:73953). Seller-local IDs are opaque values scoped by the surrounding publisher namespace. Product-level placement declarations may carry additional identifiers but SHOULD NOT contradict publisher-declared identifiers for the same type.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "$ref": "/schemas/enums/identifier-types.json"
+ },
+ "value": {
+ "type": "string",
+ "description": "Identifier value, optionally authority-prefixed for externally governed IDs (e.g., space:1234931339)."
+ }
+ },
+ "required": ["type", "value"],
+ "additionalProperties": true
+ },
+ "uniqueItems": true,
+ "minItems": 1
+ },
+ "dooh_placement_attributes": {
+ "type": "object",
+ "description": "Publisher-declared DOOH inventory facts for digital out-of-home placements. These fields describe the screen and default scheduled loop; they do not define creative acceptance, which is governed exclusively by effective canonical format_options. Each field is optional and should only be populated when it is true for the placement being represented. A single screen/frame placement can include all fields; a package/network placement should include fields only when they are uniform across the included inventory. Referencing products may override slot_duration_seconds and loop_duration_seconds for a specific offer. screen_resolution and motion are intrinsic publisher facts and MUST NOT be changed by a referencing product.",
+ "properties": {
+ "slot_duration_seconds": {
+ "type": "integer",
+ "description": "Default scheduled duration of one ad slot in seconds (e.g., 10, 15, 30). This is an inventory fact used for loop and share calculations, not the creative-duration contract; format_options remains authoritative for accepted creative durations.",
+ "minimum": 1
+ },
+ "loop_duration_seconds": {
+ "type": "integer",
+ "description": "Duration of the full ad loop rotation in seconds. Buyers can derive nominal slot share as slot_duration_seconds / loop_duration_seconds. This is the canonical source for loop duration; the pricing-layer field in flat-rate-option.json is superseded by this placement-level declaration.",
+ "minimum": 1
+ },
+ "screen_resolution": {
+ "type": "object",
+ "description": "Physical screen resolution in pixels. Buyers can derive aspect ratio from width/height. This does not replace canonical format dimensions, which remain authoritative for creative acceptance and may describe a smaller content region within the physical screen.",
+ "properties": {
+ "width": {
+ "type": "integer",
+ "description": "Screen width in pixels.",
+ "minimum": 1
+ },
+ "height": {
+ "type": "integer",
+ "description": "Screen height in pixels.",
+ "minimum": 1
+ }
+ },
+ "required": ["width", "height"],
+ "additionalProperties": false
+ },
+ "motion": {
+ "$ref": "/schemas/enums/dooh-motion-type.json",
+ "description": "Physical motion capability of the DOOH screen. This is discovery metadata, not an accepted-format declaration; effective format_options determines whether a particular full-motion, partial-motion, or static creative is accepted."
+ }
+ },
+ "x-adcp-validation": {
+ "verifier_constraints": {
+ "slot_fits_loop": {
+ "left_path": "slot_duration_seconds",
+ "operator": "less_than_or_equal",
+ "right_path": "loop_duration_seconds"
+ }
+ },
+ "spec": "docs/media-buy/product-discovery/media-products.mdx#dooh-placement-attributes"
+ },
+ "additionalProperties": true
+ },
"ext": {
"$ref": "/schemas/core/ext.json"
}
diff --git a/static/schemas/source/core/placement.json b/static/schemas/source/core/placement.json
index a44bf3d8d4..e37b04fd2a 100644
--- a/static/schemas/source/core/placement.json
+++ b/static/schemas/source/core/placement.json
@@ -93,6 +93,82 @@
},
"uniqueItems": true,
"minItems": 1
+ },
+ "identifiers": {
+ "type": "array",
+ "description": "Optional external inventory identifiers for this placement, using the same {type, value} shape as property identifiers. Externally governed IDs should be authority-prefixed (e.g., space:1234931339, geopath:30961, fcc:73953). Seller-local IDs are opaque values scoped by the surrounding publisher namespace. Useful for DOOH screen/venue IDs, broadcast facility IDs, and any channel where placements map to externally registered inventory. For kind: publisher_ref, the effective identifier set is the union of the resolved publisher declaration and this product declaration, de-duplicated by exact (type, value); a product cannot suppress a publisher-declared identifier by omission.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "$ref": "/schemas/enums/identifier-types.json"
+ },
+ "value": {
+ "type": "string",
+ "description": "Identifier value, optionally authority-prefixed for externally governed IDs (e.g., space:1234931339)."
+ }
+ },
+ "required": ["type", "value"],
+ "additionalProperties": true
+ },
+ "uniqueItems": true,
+ "minItems": 1
+ },
+ "dooh_placement_attributes": {
+ "type": "object",
+ "description": "DOOH-specific inventory facts for digital out-of-home placements. These fields describe the screen and scheduled loop; they do not define creative acceptance, which is governed exclusively by the placement's effective canonical format_options. Each field is optional and should only be populated when it is true for the placement being represented. A single screen/frame placement can include all fields; a package/network placement should include fields only when they are uniform across the included inventory. For kind: publisher_ref, resolve the publisher placement first. Product-level slot_duration_seconds and loop_duration_seconds override publisher defaults for this offer; omitted values inherit. screen_resolution and motion are intrinsic publisher facts: when repeated at product level they MUST equal the publisher values. A mismatch is a conformance error rather than an override.",
+ "properties": {
+ "slot_duration_seconds": {
+ "type": "integer",
+ "description": "Scheduled duration of one ad slot in seconds (e.g., 10, 15, 30). This is an inventory fact used for loop and share calculations, not the creative-duration contract; format_options remains authoritative for accepted creative durations.",
+ "minimum": 1
+ },
+ "loop_duration_seconds": {
+ "type": "integer",
+ "description": "Duration of the full ad loop rotation in seconds. Buyers can derive nominal slot share as slot_duration_seconds / loop_duration_seconds. This is the canonical source for loop duration; the pricing-layer field in flat-rate-option.json is superseded by this placement-level declaration.",
+ "minimum": 1
+ },
+ "screen_resolution": {
+ "type": "object",
+ "description": "Physical screen resolution in pixels. Buyers can derive aspect ratio from width/height. This does not replace or broaden canonical format dimensions, which remain authoritative for creative acceptance and may describe a smaller content region within the physical screen.",
+ "properties": {
+ "width": {
+ "type": "integer",
+ "description": "Screen width in pixels.",
+ "minimum": 1
+ },
+ "height": {
+ "type": "integer",
+ "description": "Screen height in pixels.",
+ "minimum": 1
+ }
+ },
+ "required": ["width", "height"],
+ "additionalProperties": false
+ },
+ "motion": {
+ "$ref": "/schemas/enums/dooh-motion-type.json",
+ "description": "Physical motion capability of the DOOH screen. This is discovery metadata, not an accepted-format declaration; effective format_options determines whether a particular full-motion, partial-motion, or static creative is accepted."
+ }
+ },
+ "x-adcp-validation": {
+ "verifier_constraints": {
+ "slot_fits_loop": {
+ "left_path": "slot_duration_seconds",
+ "operator": "less_than_or_equal",
+ "right_path": "loop_duration_seconds",
+ "evaluate_after": "publisher_ref_resolution"
+ },
+ "publisher_ref_resolution": {
+ "override_fields": ["slot_duration_seconds", "loop_duration_seconds"],
+ "inherit_when_omitted": true,
+ "must_equal_fields": ["screen_resolution", "motion"],
+ "identifier_merge": "union_by_type_and_value"
+ }
+ },
+ "spec": "docs/media-buy/product-discovery/media-products.mdx#dooh-placement-attributes"
+ },
+ "additionalProperties": true
}
},
"required": ["kind", "placement_id", "mode"],
diff --git a/static/schemas/source/enums/dooh-motion-type.json b/static/schemas/source/enums/dooh-motion-type.json
new file mode 100644
index 0000000000..403fb47175
--- /dev/null
+++ b/static/schemas/source/enums/dooh-motion-type.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "/schemas/enums/dooh-motion-type.json",
+ "title": "DOOH Motion Type",
+ "description": "Physical motion capability of a digital out-of-home screen. This is inventory discovery metadata; canonical format_options remains the authoritative declaration of which creatives the placement accepts.",
+ "type": "string",
+ "enum": [
+ "full_motion",
+ "partial_motion",
+ "static"
+ ],
+ "enumDescriptions": {
+ "full_motion": "Screen supports full-motion video playback (e.g., LED billboards, transit screens with video capability)",
+ "partial_motion": "Screen supports animated stills or limited motion (e.g., scrolling text, animated GIFs, HTML5 banners) but not full video",
+ "static": "Screen displays static images only (e.g., e-ink displays, printed poster replacements)"
+ },
+ "examples": [
+ "full_motion",
+ "static"
+ ]
+}
diff --git a/static/schemas/source/enums/identifier-types.json b/static/schemas/source/enums/identifier-types.json
index 0b6bf40e4b..e7fd411441 100644
--- a/static/schemas/source/enums/identifier-types.json
+++ b/static/schemas/source/enums/identifier-types.json
@@ -1,8 +1,8 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "/schemas/enums/identifier-types.json",
- "title": "Property Identifier Types",
- "description": "Valid identifier types for property identification across different media types",
+ "title": "Identifier Types",
+ "description": "Valid identifier types for property and placement identification across different media types",
"type": "string",
"enum": [
"domain",
@@ -42,7 +42,7 @@
"bundle_id": "Generic app bundle identifier",
"venue_id": "DOOH venue identifier",
"screen_id": "DOOH screen identifier within a venue",
- "openooh_venue_type": "OpenOOH venue taxonomy classification",
+ "openooh_venue_type": "OpenOOH Venue Taxonomy enumeration ID, prefixed with the taxonomy authority and version for unambiguous interchange (for example openooh-1.1:20501 for Retail / Malls / Concourse). Bare or dotted hierarchy values are ambiguous and SHOULD NOT be emitted by new senders.",
"rss_url": "RSS feed URL for podcast or content feed",
"apple_podcast_id": "Apple Podcasts numeric ID",
"spotify_collection_id": "Spotify show or collection URI",
diff --git a/static/schemas/source/enums/specialism.json b/static/schemas/source/enums/specialism.json
index a65cbe39ac..f69073f614 100644
--- a/static/schemas/source/enums/specialism.json
+++ b/static/schemas/source/enums/specialism.json
@@ -24,6 +24,7 @@
"property-lists",
"sales-broadcast-tv",
"sales-catalog-driven",
+ "sales-dooh",
"sales-guaranteed",
"sales-non-guaranteed",
"sales-proposal-mode",
@@ -48,6 +49,7 @@
"property-lists": "Property list governance — curated inclusion and exclusion lists for targeting and delivery compliance",
"sales-broadcast-tv": "Broadcast linear TV seller with guaranteed inventory and FCC cancellation rules",
"sales-catalog-driven": "Catalog-driven commerce with conversion tracking",
+ "sales-dooh": "Digital out-of-home seller with non-guaranteed venue and screen inventory, canonical screen formats, and play plus DOOH measurement reporting",
"sales-guaranteed": "Guaranteed media buys with human IO approval",
"sales-non-guaranteed": "Non-guaranteed auction-based media buys",
"sales-proposal-mode": "DEPRECATED in 3.1 — proposal-driven flows are part of `sales-guaranteed` (RFP → proposal → finalize → IO → live is one lifecycle). The `media_buy_seller/proposal_finalize` scenario lives under `sales-guaranteed.requires_scenarios` and is capability-gated on `media_buy.supports_proposals`: full-service guaranteed sellers declare `true` (and are graded against the proposal lifecycle); direct-buy guaranteed sellers (auction PG, retail SKU, quoted-rate; no RFP) declare `false` and the runner skips it as `capability_unsupported`. Sellers that previously declared `sales-proposal-mode` should drop it and declare `sales-guaranteed` plus `media_buy.supports_proposals: true` instead. This enum value is retained through 3.x for backward compat and removed in 4.0. See https://github.com/adcontextprotocol/adcp/issues/3823 (taxonomy consolidation) and https://github.com/adcontextprotocol/adcp/issues/3844 (capability flag).",
diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json
index 81c0c82755..bf17a16c14 100644
--- a/static/schemas/source/index.json
+++ b/static/schemas/source/index.json
@@ -1091,7 +1091,7 @@
},
"identifier-types": {
"$ref": "/schemas/enums/identifier-types.json",
- "description": "Valid identifier types for property identification across different media types"
+ "description": "Valid identifier types for property and placement identification across different media types"
},
"publisher-identifier-types": {
"$ref": "/schemas/enums/publisher-identifier-types.json",
@@ -1117,6 +1117,10 @@
"$ref": "/schemas/enums/social-placement-surface.json",
"description": "Declared social-placement surface classifications for social inventory"
},
+ "dooh-motion-type": {
+ "$ref": "/schemas/enums/dooh-motion-type.json",
+ "description": "Physical motion capabilities of digital out-of-home screens"
+ },
"task-status": {
"$ref": "/schemas/enums/task-status.json",
"description": "Standardized task status values based on A2A TaskState enum"
diff --git a/static/schemas/source/pricing-options/flat-rate-option.json b/static/schemas/source/pricing-options/flat-rate-option.json
index 413348a486..a11e663d95 100644
--- a/static/schemas/source/pricing-options/flat-rate-option.json
+++ b/static/schemas/source/pricing-options/flat-rate-option.json
@@ -52,8 +52,14 @@
},
"loop_duration_seconds": {
"type": "integer",
- "description": "Duration of the ad loop rotation in seconds",
- "minimum": 1
+ "description": "Deprecated compatibility copy of the placement-level dooh_placement_attributes.loop_duration_seconds, which is the canonical source. Retained for backward compatibility; new integrations read the placement-level field. When both are present they MUST agree. A product that offers different loop durations under different prices MUST expose distinct placements or products rather than vary this compatibility copy by pricing option.",
+ "minimum": 1,
+ "x-adcp-validation": {
+ "verifier_constraints": {
+ "must_equal_effective_placement_field": "dooh_placement_attributes.loop_duration_seconds"
+ },
+ "spec": "docs/media-buy/advanced-topics/pricing-models.mdx#flat-rate"
+ }
},
"min_plays_per_hour": {
"type": "integer",
diff --git a/tests/placement-catalog-schema.test.cjs b/tests/placement-catalog-schema.test.cjs
index a5118821c8..818c5b2c22 100644
--- a/tests/placement-catalog-schema.test.cjs
+++ b/tests/placement-catalog-schema.test.cjs
@@ -2,6 +2,7 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
+const { isDeepStrictEqual } = require('node:util');
const Ajv = require('ajv');
const addFormats = require('ajv-formats');
@@ -30,6 +31,42 @@ async function compile(schemaId) {
return ajv.compileAsync(JSON.parse(fs.readFileSync(schemaPathFromId(schemaId), 'utf8')));
}
+function resolveDoohPlacement(publisherPlacement = {}, productPlacement = {}) {
+ const publisherAttributes = publisherPlacement.dooh_placement_attributes || {};
+ const productAttributes = productPlacement.dooh_placement_attributes || {};
+ for (const field of ['screen_resolution', 'motion']) {
+ if (
+ publisherAttributes[field] !== undefined &&
+ productAttributes[field] !== undefined &&
+ !isDeepStrictEqual(publisherAttributes[field], productAttributes[field])
+ ) {
+ throw new Error(`${field} conflicts with the publisher placement`);
+ }
+ }
+
+ const effective = { ...publisherAttributes, ...productAttributes };
+ if (
+ effective.slot_duration_seconds !== undefined &&
+ effective.loop_duration_seconds !== undefined &&
+ effective.slot_duration_seconds > effective.loop_duration_seconds
+ ) {
+ throw new Error('slot_duration_seconds exceeds loop_duration_seconds');
+ }
+ const identifiers = [...(publisherPlacement.identifiers || []), ...(productPlacement.identifiers || [])].filter(
+ (identifier, index, all) =>
+ all.findIndex((candidate) => candidate.type === identifier.type && candidate.value === identifier.value) === index
+ );
+ return { dooh_placement_attributes: effective, identifiers };
+}
+
+function validateDoohPricingLoop(effectivePlacement, pricingOption) {
+ const placementLoop = effectivePlacement.dooh_placement_attributes?.loop_duration_seconds;
+ const pricingLoop = pricingOption.parameters?.loop_duration_seconds;
+ if (placementLoop !== undefined && pricingLoop !== undefined && placementLoop !== pricingLoop) {
+ throw new Error('pricing loop_duration_seconds conflicts with the effective placement');
+ }
+}
+
function validProduct(overrides = {}) {
return {
product_id: 'homepage_sponsorship',
@@ -600,3 +637,148 @@ test('format options can be referenced by publisher domain or product-local ID',
false
);
});
+
+test('placement-definition accepts dooh_placement_attributes and identifiers', async () => {
+ const validate = await compile('/schemas/core/placement-definition.json');
+ const placement = {
+ placement_id: 'mall_concourse_north',
+ name: 'Mall concourse north LED',
+ property_ids: ['mall_concourse_network'],
+ channels: ['dooh'],
+ identifiers: [
+ { type: 'screen_id', value: 'mall-north-001' },
+ { type: 'openooh_venue_type', value: 'openooh-1.1:20501' }
+ ],
+ dooh_placement_attributes: {
+ slot_duration_seconds: 15,
+ loop_duration_seconds: 120,
+ screen_resolution: { width: 1920, height: 1080 },
+ motion: 'full_motion'
+ }
+ };
+
+ assert.equal(validate(placement), true, JSON.stringify(validate.errors, null, 2));
+});
+
+test('product placement accepts dooh_placement_attributes and identifiers', async () => {
+ const validate = await compile('/schemas/core/placement.json');
+ const placement = {
+ kind: 'seller_inline',
+ placement_id: 'mall_entrance_screen',
+ name: 'Mall Entrance Digital Screen',
+ mode: 'targetable',
+ identifiers: [
+ { type: 'venue_id', value: 'geopath:30961' }
+ ],
+ dooh_placement_attributes: {
+ slot_duration_seconds: 10,
+ loop_duration_seconds: 60,
+ screen_resolution: { width: 3840, height: 2160 },
+ motion: 'full_motion'
+ }
+ };
+
+ assert.equal(validate(placement), true, JSON.stringify(validate.errors, null, 2));
+});
+
+test('canonical list_products placements preserve DOOH inventory facts', async () => {
+ const validate = await compile('/schemas/core/canonical-placement.json');
+ const placement = {
+ kind: 'seller_inline',
+ placement_id: 'central_concourse_portrait_screens',
+ publisher_domain: 'metro-media.example',
+ name: 'Central concourse portrait screens',
+ mode: 'included',
+ identifiers: [
+ { type: 'venue_id', value: 'metro:central-concourse' }
+ ],
+ dooh_placement_attributes: {
+ slot_duration_seconds: 10,
+ loop_duration_seconds: 80,
+ screen_resolution: { width: 1080, height: 1920 },
+ motion: 'full_motion'
+ }
+ };
+
+ assert.equal(validate(placement), true, JSON.stringify(validate.errors, null, 2));
+});
+
+test('dooh_placement_attributes rejects invalid motion type', async () => {
+ const validate = await compile('/schemas/core/placement.json');
+ const placement = {
+ kind: 'seller_inline',
+ placement_id: 'bus_shelter_01',
+ name: 'Bus Shelter Panel',
+ mode: 'targetable',
+ dooh_placement_attributes: {
+ motion: 'invalid_motion_type'
+ }
+ };
+
+ assert.equal(validate(placement), false);
+});
+
+test('DOOH placement schemas declare cross-field and publisher-resolution rules', () => {
+ const productPlacement = require('../static/schemas/source/core/placement.json');
+ const canonicalPlacement = require('../static/schemas/source/core/canonical-placement.json');
+ const publisherPlacement = require('../static/schemas/source/core/placement-definition.json');
+ const productRules = productPlacement.properties.dooh_placement_attributes['x-adcp-validation'].verifier_constraints;
+ const canonicalRules = canonicalPlacement.properties.dooh_placement_attributes['x-adcp-validation'].verifier_constraints;
+ const publisherRules = publisherPlacement.properties.dooh_placement_attributes['x-adcp-validation'].verifier_constraints;
+
+ assert.equal(productRules.slot_fits_loop.operator, 'less_than_or_equal');
+ assert.equal(productRules.slot_fits_loop.evaluate_after, 'publisher_ref_resolution');
+ assert.equal(publisherRules.slot_fits_loop.operator, 'less_than_or_equal');
+ assert.deepEqual(productRules.publisher_ref_resolution.must_equal_fields, ['screen_resolution', 'motion']);
+ assert.deepEqual(canonicalRules, productRules);
+});
+
+test('DOOH publisher and product attributes resolve before slot-to-loop validation', () => {
+ const publisher = {
+ identifiers: [
+ { type: 'screen_id', value: 'space:screen-1' },
+ { type: 'openooh_venue_type', value: 'openooh-1.1:20501' }
+ ],
+ dooh_placement_attributes: {
+ slot_duration_seconds: 15,
+ loop_duration_seconds: 60,
+ screen_resolution: { width: 1920, height: 1080 },
+ motion: 'full_motion'
+ }
+ };
+
+ const effective = resolveDoohPlacement(publisher, {
+ identifiers: [
+ { type: 'screen_id', value: 'space:screen-1' },
+ { type: 'venue_id', value: 'geopath:30961' }
+ ],
+ dooh_placement_attributes: { slot_duration_seconds: 10 }
+ });
+ assert.deepEqual(effective.dooh_placement_attributes, {
+ slot_duration_seconds: 10,
+ loop_duration_seconds: 60,
+ screen_resolution: { width: 1920, height: 1080 },
+ motion: 'full_motion'
+ });
+ assert.deepEqual(effective.identifiers, [
+ { type: 'screen_id', value: 'space:screen-1' },
+ { type: 'openooh_venue_type', value: 'openooh-1.1:20501' },
+ { type: 'venue_id', value: 'geopath:30961' }
+ ]);
+ assert.throws(
+ () => resolveDoohPlacement(publisher, { dooh_placement_attributes: { slot_duration_seconds: 90 } }),
+ /exceeds loop_duration_seconds/
+ );
+ assert.throws(
+ () => resolveDoohPlacement(publisher, { dooh_placement_attributes: { motion: 'static' } }),
+ /conflicts with the publisher placement/
+ );
+
+ assert.doesNotThrow(() =>
+ validateDoohPricingLoop(effective, { parameters: { type: 'dooh', loop_duration_seconds: 60 } })
+ );
+ assert.throws(
+ () => validateDoohPricingLoop(effective, { parameters: { type: 'dooh', loop_duration_seconds: 90 } }),
+ /conflicts with the effective placement/
+ );
+});