diff --git a/.changeset/2911-mcp-envelope-flat-serialization.md b/.changeset/2911-mcp-envelope-flat-serialization.md new file mode 100644 index 0000000000..f5840a8d71 --- /dev/null +++ b/.changeset/2911-mcp-envelope-flat-serialization.md @@ -0,0 +1,41 @@ +--- +"adcontextprotocol": minor +--- + +spec(envelope): normalize MCP envelope serialization (flat root, drop `payload.required`, `context` joins envelope). + +`core/protocol-envelope.json` declared `required: [status, payload]` with `payload` as a nested object, but every shipping SDK (`@adcp/client`) emits the flat MCP shape — envelope fields and body fields as siblings at the root, no nested `payload:` key. Task response schemas like `media-buy/get-products-response.json` declared body fields at the root, not under `payload`. The schema's literal reading contradicted the deployed reality. Two prior triage rounds (2026-04-23, two separate sessions) converged on the same call: ratify the flat-on-the-wire behavior, add `context` as a first-class envelope field distinct from `context_id`, and drop the `payload.required` constraint. + +Going with that resolution: + +- **`payload.required` dropped.** `payload` becomes a documentary grouping construct, NOT a required wire key. The schema's `required:` is now empty (the `not` block rejecting legacy `task_status` / `response_status` stays). Per-transport serialization is normative in `notes`: + - **MCP**: envelope fields and body fields are siblings at the root of the tool response. No nested `payload:` key. Matches MCP's `structuredContent` convention. + - **A2A**: envelope fields map to transport-native task metadata (`task.status.state`, `task.contextId`, `task.id`); body fields appear inside `task.artifacts[0].parts[].DataPart` (final) or `task.status.message.parts[].DataPart` (interim). + - **REST**: envelope fields MAY ride headers or body siblings; body fields appear at the JSON body root. +- **`context` joins the envelope as a first-class field**, `$ref` to `core/context.json`. Semantically orthogonal to `context_id`: + - `context_id` — server-managed session identifier. + - `context` — caller-supplied opaque echo, preserved byte-for-byte by the agent. + - Both MAY appear on the same response; they are NOT aliases. +- **`description` rewritten** to lead with the canonical-field-set framing rather than the "wraps the payload" mental model the old text used (which encouraged the nested-`payload` misreading). + +Producer and receiver rules added to `docs/building/by-layer/L0/mcp-guide.mdx` so the wire shape is normative from both ends: +- MCP tool implementations MUST emit envelope and body fields as flat siblings at root. +- MCP tool consumers MUST parse from the flat root; receivers MUST NOT require a nested `payload:` key. +- `context_id` vs `context` distinction surfaced with one-line definitions and the "both may appear" clause. + +Why this resolution over "make nested canonical and migrate `@adcp/client`": +- The flat shape is what every shipping integrator has parsed against since 3.0 GA. Declaring it non-conformant before any peer SDK ships inverts the codify-deployed-behavior precedent the ecosystem already follows (OpenRTB, prebid, GAM). +- MCP's native conventions favor flat — `structuredContent` is itself a flat field; nesting `payload:` inside it is ceremonial boilerplate. +- A2A's transport-native task metadata already carries the envelope fields; nesting `payload:` would force redundant double-wrapping. + +Why `context` joins as a peer of `context_id` rather than a convention: +- `get-products-response.json:147` already `$ref`s `core/context.json` for per-request echo. The convention is in use; it just never made it into the envelope doc. +- Splitting on `_id` (session identifier) vs `context` (per-request echo) is the same split A2A makes between `task.contextId` and `task.metadata`; not codifying it leaves the spec less expressive than the transports it runs over. + +Files: +- `static/schemas/source/core/protocol-envelope.json` — description rewritten; `context` added; `payload` description clarified as documentary grouping; `required: [status, payload]` removed; `notes` array rewritten with normative per-transport serialization. +- `docs/building/by-layer/L0/mcp-guide.mdx` — `## MCP Response Format` section rewritten with normative producer + receiver rules and the `context_id` / `context` distinction. + +Validation: `composed-schema-validation.test.cjs` (43 tests) passes against the changed envelope. Existing SDKs (`@adcp/client`) remain conformant. + +Closes #2911. Unblocks adcp-client#832 (per-field envelope validation). diff --git a/.changeset/4043-proposal-not-found-error-code.md b/.changeset/4043-proposal-not-found-error-code.md new file mode 100644 index 0000000000..153640955f --- /dev/null +++ b/.changeset/4043-proposal-not-found-error-code.md @@ -0,0 +1,18 @@ +--- +"adcontextprotocol": minor +--- + +spec(errors): add `PROPOSAL_NOT_FOUND` to the canonical error catalog. + +Counterpart to existing `PROPOSAL_EXPIRED` (known proposal whose `expires_at` window has passed) and `PROPOSAL_NOT_COMMITTED` (known proposal still in `draft`). `PROPOSAL_NOT_FOUND` covers the third proposal-lifecycle failure mode: the seller doesn't recognize the `proposal_id` at all — never finalized, belongs to a different tenant, or evicted from session cache before consumption. + +Without this code, sellers had to reuse `INVALID_REQUEST` (loses semantics, wrong recovery class) or invent local codes (no cross-SDK consistency). The Python SDK's v1.5 ProposalManager (adcp-client-python#538) was shipping `PROPOSAL_NOT_FOUND` via its `KNOWN_NON_SPEC_CODES` allowlist as a stopgap, same pattern as `CONFIGURATION_ERROR` from #3995. + +Recovery: `correctable` — buyer should re-issue `get_products` with `buying_mode: 'refine'` + `action: 'finalize'` to obtain a current `proposal_id`, then retry `create_media_buy`. + +Files: +- `static/schemas/source/enums/error-code.json` — code added to `enum`, `enumDescriptions`, and `enumMetadata` (recovery + suggestion) per the three-parallel-structures convention. +- `scripts/error-code-drift-dispositions.json` — `held-for-next-minor` for target_version `3.1` (PROPOSAL_EXPIRED / PROPOSAL_NOT_COMMITTED are already on 3.0.x; PROPOSAL_NOT_FOUND is the new AHEAD code). +- `docs/media-buy/task-reference/get_products.mdx`, `docs/media-buy/product-discovery/refinement.mdx`, `docs/building/by-layer/L3/error-handling.mdx`, `docs/building/operating/transport-errors.mdx` — error-table rows alongside `PROPOSAL_EXPIRED`. + +Closes #4043. diff --git a/.changeset/4107-refine-finalize-exclusivity.md b/.changeset/4107-refine-finalize-exclusivity.md new file mode 100644 index 0000000000..ddbf5c6a09 --- /dev/null +++ b/.changeset/4107-refine-finalize-exclusivity.md @@ -0,0 +1,26 @@ +--- +"adcontextprotocol": minor +--- + +spec(media-buy): clarify finalize-exclusivity and multi-finalize atomicity in `get_products` `refine[]`. + +The 3.0.6 spec allows multiple `refine[]` entries and matches `refinement_applied[]` by position, but was silent on what a seller does when one entry has `action: 'finalize'` and others don't. Two adopting SDKs (`adcp-client`, `adcp-client-python`) settled on "process the first finalize; silently drop the rest" — undocumented, divergent across wrappers, and inconsistent with the existing `proposal_finalize` compliance scenario which keeps refine and finalize on separate steps. The conformance harness couldn't enforce a contract because the spec hadn't picked one. + +Picked **option (a) — finalize is exclusive within `refine[]`** with explicit multi-finalize atomicity: + +- If any entry has `action: 'finalize'`, **all** entries in the array MUST be proposal-scoped finalize entries. Mixing finalize with `include` / `omit` or with request- / product-scoped entries MUST be rejected with `INVALID_REQUEST`. +- Multi-finalize against different `proposal_id`s in one call is allowed and MUST be **atomic** — all proposals commit or none do; partial commits are non-conformant. Sellers that cannot guarantee atomic multi-proposal commit MUST reject multi-finalize arrays with `INVALID_REQUEST` and name the constraint in `error.message`. +- No capability flag for multi-finalize — the failure response is the discovery surface, so buyers MUST NOT assume support without a successful first attempt. + +Why (a) over (b) "finalize-with-ordered-refinement" or (c) "implementation-defined": +- (a) matches the existing `proposal_finalize.yaml` compliance scenario, which already separates refine and finalize into distinct phases (`refine_proposal` has no finalize; `finalize_proposal` has only finalize). +- (b) introduces ordering + partial-failure semantics across mixed entries, expanding the seller state machine for no buyer-side win (the buyer who wants both can sequence the calls trivially). +- (c) leaves divergent SDK behavior in the field and is exactly the gap this issue asks to close. + +Files: +- `static/schemas/source/media-buy/get-products-request.json` — `refine` field description gains the finalize-exclusivity and multi-finalize atomicity contract. `action.finalize` enum description cross-references the array-level rule. +- `docs/media-buy/product-discovery/refinement.mdx` — new `## Finalize is exclusive within refine[]` section before `## Proposals in refine mode` with ✅/❌ examples and the multi-finalize atomicity contract. + +SDK alignment: `adcp-client`'s `detectFinalizeAction` and `adcp-client-python`'s `detect_finalize_action` should reject mixed arrays at the SDK layer rather than silently dropping non-finalize siblings; tracked separately in those repos. + +Closes #4107. diff --git a/.changeset/4196-pending-creatives-disambiguation.md b/.changeset/4196-pending-creatives-disambiguation.md new file mode 100644 index 0000000000..1ab4ee37c9 --- /dev/null +++ b/.changeset/4196-pending-creatives-disambiguation.md @@ -0,0 +1,13 @@ +--- +"adcontextprotocol": minor +--- + +spec(media-buy): disambiguate `pending_creatives` status description. + +Sharpens the enum description for `media-buy-status.pending_creatives` to remove the ambiguity raised in #4196 (readers interpreting the name as "waiting for publisher/governance approval" rather than the intended "buyer-side creative submission missing"). + +Document, don't rename. The wire churn of renaming the enum value isn't worth the marginal clarity gain — the existing description already named the buyer action, and `pending_X` is a consistent naming convention across the enum (`pending_start` follows the same shape: "phase X is next required", not "X is pending approval"). Renaming would force every downstream SDK, dashboard, storyboard fixture, and seller-side state machine to migrate for what is fundamentally a documentation gap. + +The new description leads with **"Buyer-side action required"**, explicitly contrasts with publisher/governance approval flows ("the seller has already accepted the buy"), and names the convention so readers can apply the same parse to `pending_start` without filing a follow-up issue. + +Closes #4196. diff --git a/.changeset/4227-error-code-forward-compatible-decoding.md b/.changeset/4227-error-code-forward-compatible-decoding.md new file mode 100644 index 0000000000..1cb049b30b --- /dev/null +++ b/.changeset/4227-error-code-forward-compatible-decoding.md @@ -0,0 +1,21 @@ +--- +"adcontextprotocol": minor +--- + +spec(errors): make `error.code` forward-compatible decoding normative. + +The drift lint shipped in #4221 enforces a strict policy: adding a new code to `error-code.json` is a wire change held to the next minor, because a 3.0.x receiver decoding a 3.1 sender's `error.code` has no contract that says it must accept the unknown value. The closed-enum hazard was prose-level in `error-code.json` ("agents MUST handle unknown codes gracefully by falling back to the recovery classification") but not surfaced as a normative receiver rule in the spec body — strict validators reading `core/error.json` would have rejected unknown codes anyway. `core/error.json` already types `error.code` as `string` (not as a closed enum reference), so the wire was already open at the envelope level; what was missing was the receiver contract that says so explicitly, and the sender contract that says `error.recovery` is the normative carrier across version skew. + +This change makes that explicit: + +- **Receivers MUST decode unknown codes**, recover the recovery class from `error.recovery`, and default to `transient` when `recovery` is absent (matches the manifest's `error_code_policy.default_unknown_recovery`). +- **Senders MAY emit codes outside the receiver's pinned vocabulary** — newer codes, platform-specific codes — and MUST populate `error.recovery` on every error from 3.1 onward so receivers across version skew can classify reliably. +- **`error.recovery` is the normative wire carrier**; `enumMetadata.recovery` in `error-code.json` is the documentary mirror for known codes. + +3.0.x policy unchanged — 3.0.x receivers predate this rule, so 3.0.x stays wire-stable for the rest of its support window. From 3.1 onward, future maintenance lines can ship new codes additively (3.1.5 adds a code; 3.1.0 receivers handle it via `error.recovery`) instead of every code being held to the next minor. + +Files: +- `static/schemas/source/core/error.json` — `error.code` description elevated from "agents MUST handle unknown codes" prose to a wire-level rule pointing at `error-handling.mdx#forward-compatible-decoding-normative`. `error.recovery` description states it as the normative carrier across version skew. +- `docs/building/by-layer/L3/error-handling.mdx` — new `### Forward-compatible decoding (normative)` section under `## Standard Error Codes` with the full receiver / sender / `error.recovery` contract and the "why this matters" / "3.0.x policy unchanged" carve-outs. Best-practice list item updated to point at the new section. + +Refs #4227. Pairs with #3725 / #3738 (`enumMetadata.recovery`) and #4221 (the drift lint). diff --git a/.changeset/4371-idempotency-replay-state-snapshot.md b/.changeset/4371-idempotency-replay-state-snapshot.md new file mode 100644 index 0000000000..b40c00c792 --- /dev/null +++ b/.changeset/4371-idempotency-replay-state-snapshot.md @@ -0,0 +1,24 @@ +--- +"adcontextprotocol": minor +--- + +spec(security): clarify idempotency-replay semantics for state-tracking fields on stateful resources. + +The existing idempotency contract (security.mdx §Idempotency rule 2) made the immutable-cache invariant explicit for async (`submitted`) responses — even if the underlying task transitions to a terminal state, replay returns the originally-cached `submitted` payload, not the current state — but was silent on synchronous-success responses that carry state-tracking fields inline (`status` on `create_media_buy`, per-record arrays on `sync_*`, resource snapshots on `acquire_rights` / `activate_signal`). The gap surfaced in real storyboard runs: a media buy created with `status: pending_creatives`, then mutated to `canceled`, then replayed via the same `idempotency_key` returned the cached `pending_creatives` bytes. A buyer that trusted the response as current state hit `NOT_CANCELLABLE` on the next mutation and a state-machine bug. Three options surfaced: + +1. **Replay returns cached bytes verbatim** — what sellers do today; preserves byte-stable replay; buyers must re-read for current state. +2. **Replay returns current state** — what buyers reading the bytes expected; breaks byte-stable replay and forces sellers to refresh the cache on every resource mutation. +3. **Capability-declared** — sellers advertise their replay policy. + +Picked (1) and made it normative across both branches: + +- Seller rule 2 extended explicitly to synchronous-success responses. State-tracking fields in the cached payload MUST NOT refresh on replay. Partial refresh ("some fields current, others snapshot") is non-conformant — it would multiply the number of valid cache contents for a given key and break the canonical-replay invariant the rest of the rules build on. +- New buyer-obligation paragraph: **Replay responses are historical snapshots.** Buyers requiring current state MUST consult the resource's read endpoint (`get_media_buys`, `list_accounts`, `list_creatives`, etc.). `replayed: true` is the explicit signal that a fresh read is required before any state-dependent decision. Agentic buyers MUST treat `replayed: true` as a stop signal for any planning step whose next action depends on resource state. +- `Response-level replay indicator` gains a `State-machine routing` bullet pointing back at the seller rule and buyer obligation so the contract reads consistently from either entry point. + +Why (1) over (2) or (3): (2) forces every seller to thread the resource state machine through the idempotency cache (multiplying valid cache contents and breaking byte-stable replay). (3) adds capability surface for a question the spec should answer uniformly — heterogeneous replay semantics across sellers is exactly the kind of cross-seller inconsistency the idempotency contract exists to prevent. (1) is what existing sellers do; the gap was the contract being silent on sync-success, not divergent behavior. + +Files: +- `docs/building/by-layer/L1/security.mdx` — seller rule 2 expanded (async + synchronous-success branches); new "Replay responses are historical snapshots" paragraph under "Buyer obligations"; `Response-level replay indicator` list gains the state-machine-routing bullet. + +Closes #4371. diff --git a/.changeset/4399-mcp-tool-wrapper-envelope-tolerance.md b/.changeset/4399-mcp-tool-wrapper-envelope-tolerance.md new file mode 100644 index 0000000000..494b430e25 --- /dev/null +++ b/.changeset/4399-mcp-tool-wrapper-envelope-tolerance.md @@ -0,0 +1,19 @@ +--- +"adcontextprotocol": minor +--- + +spec(mcp,security): require MCP tool wrappers to tolerate envelope-level fields. + +Buyer SDKs send envelope-level fields (`idempotency_key`, `context_id`, `context`, `governance_context`, `push_notification_config`) uniformly across all AdCP tool calls — including read-only tools that don't consume them. Buyers cannot know per-tool which envelope fields the seller's wrapper happens to declare, and the wire-level contract via `additionalProperties: true` on every published request schema permits them. + +Some MCP server implementations apply stricter validation than the schema declares — FastMCP / Pydantic with declared signatures raises `unexpected_keyword_argument`, Zod `.strict()` rejects unknown keys, OpenAPI codegen sometimes injects `additionalProperties: false` into input models. The result: read tools like `get_products` reject calls when `idempotency_key` arrives in params, breaking cross-seller portability the protocol promises. + +This is the server-side counterpart to the `additionalProperties: true` default — generalizing the principle already established for response validators in [`runner-output-contract.yaml` > `response_schema_validator_semantics`](https://github.com/adcontextprotocol/adcp/blob/main/static/compliance/source/universal/runner-output-contract.yaml) ("validator configuration MUST NOT contradict the schema's own `additionalProperties` declaration") to the request side. + +Files: +- `docs/building/by-layer/L1/security.mdx` — new `#### Server-side tool wrapper conformance` subsection under §Idempotency (the most-affected envelope field). Concrete traps and fixes named for FastMCP/Pydantic, Zod/valibot, and OpenAPI codegen. +- `docs/building/by-layer/L0/mcp-guide.mdx` — new `### Server-side tool wrappers MUST tolerate envelope fields` subsection under §MCP-Specific Considerations, cross-linking to the security.mdx normative rule. Concrete traps and one-line fixes for the three common stacks. + +Confirmed pre-existing in the wild — issue filer (#4399) hit it in production against a real seller, fixed in the seller's Wave 23.20 by adding `idempotency_key: str | None = None` to read-tool wrapper signatures. + +Closes #4399. diff --git a/.changeset/4399b-universal-idempotency-key.md b/.changeset/4399b-universal-idempotency-key.md new file mode 100644 index 0000000000..c8b932aa74 --- /dev/null +++ b/.changeset/4399b-universal-idempotency-key.md @@ -0,0 +1,31 @@ +--- +"adcontextprotocol": minor +--- + +spec(security): require `idempotency_key` on every AdCP task request — read and mutating alike. + +Follow-up to #4399 (MCP tool wrapper envelope tolerance) — the deeper question that surfaced once that fix landed: why does this category of bug exist at all? Sellers reject `idempotency_key` on `get_products` because the contract framed it as a "mutating-only" envelope field, but `get_products` is polymorphic: + +- `buying_mode: 'brief'` / `'wholesale'` resolves as a pure read most of the time. +- The same tool MAY return a `Submitted` envelope when curation requires upstream queries or HITL — that's async-task creation, which is mutation territory. +- `buying_mode: 'refine'` with `action: 'finalize'` is a commit that transitions a proposal to committed with an `expires_at` hold window (see #4107). + +Buyers cannot predict at call time which mode the seller will resolve. So the rule "send `idempotency_key` on mutating requests only" required classification the buyer can't do, and the rule "sellers reject mutating requests that omit it" left sellers tripping over reads that turned into mutations or carried the field uniformly. + +The simpler rule: `idempotency_key` is required on every AdCP task request, period. Read and mutating alike. The buyer no longer classifies; the seller no longer rejects on the read/write distinction; the polymorphism on `get_products` (and any future tool that gains hybrid read/write modes) stops being a wire-contract footgun. + +For calls that resolve as pure reads, the cache provides byte-stable replay-on-retry within the TTL — harmless and gives buyers a uniform retry-safe contract. For calls that resolve as async-task creation or commit, the cache provides the same at-most-once guarantees as on mutating tasks. The rate-limit ceiling in rule 8 already accounts for high-volume traffic; read traffic adds to insert rate but the ceiling is tunable per operator. + +Files (`docs/building/by-layer/L1/security.mdx`): +- §Idempotency rule 1 lead — "required on every AdCP task request — read and mutating alike". Drops the long list of mutating task names (the list was always going to drift as new tools shipped). +- New `**Why universal — including read tools.**` paragraph naming `get_products`'s polymorphism as the canonical case. +- §Response-level replay indicator — "responses to any request that resolved via the idempotency cache" (was "responses to mutating requests"). +- §Buyer obligations / "When the seller's capability declaration is missing" — fail-closed now applies to every AdCP task request, with explicit reasoning about why pure-read calls aren't exempt under polymorphism. +- §Server-side tool wrapper conformance (added in #4399) — `idempotency_key` line tightened from "MUST accept and ignore on read tools" to "MUST accept it; the idempotency layer routes it per rules 2-9". + +Why this over keeping the mutating-only rule and just fixing #4399's wrapper bug: +- The wrapper bug was a symptom of the binary contract being wrong-shaped. Patching the symptom (sellers must accept envelope fields) without fixing the binary leaves future polymorphic tools (anything that can return Submitted) hitting the same class of failure. +- "Cleaner and simpler" beats "send-on-mutating-only" once the polymorphism exists — the buyer's SDK doesn't need a read-vs-write classifier and the seller's wrapper doesn't need to know which mode a call resolved into before it sees the key. +- Cache-growth concern bounded by rule 8 (per-agent insert ceiling); the recommended numbers were sized for realistic high-volume launch patterns and remain tunable. + +Refs #4399. Supersedes the "MUST tolerate on read tools" carve-out — `idempotency_key` is now required, not tolerated. diff --git a/.changeset/4418-runner-output-notices-field.md b/.changeset/4418-runner-output-notices-field.md new file mode 100644 index 0000000000..7e781b80a4 --- /dev/null +++ b/.changeset/4418-runner-output-notices-field.md @@ -0,0 +1,30 @@ +--- +"adcontextprotocol": minor +--- + +spec(compliance): standardize `notices` advisory channel on runner-output-contract. + +`universal/signed-requests.yaml` already mandates an "informational notice (not a failure)" for agents that still advertise the deprecated `signed-requests` specialism — but the contract had no field for it. Runners had two bad options: bake the advisory into prose `skip.detail` strings (unparseable by dashboards), or stay silent and let sellers hit a wall at the 4.0 cut where `request_signing` becomes required and `legacy_hmac_fallback` is removed. + +Adds a structured advisory channel: + +- **`step_result.notices`** — per-step advisory array. +- **`run_summary.notices`** — run-scoped advisories (e.g., one `request_signing_required_in_4_0` notice per run, not per storyboard). +- Notices MUST NOT contribute to `steps_failed`, `validations_failed`, or change `step_result.passed`. They fill the gap between validation failures (agent did something wrong), skips (runner couldn't apply the storyboard), and advisory-severity validations (storyboard author marked a check non-blocking) — none of which fit "passing observation, but here's a forward-looking advisory." +- Three severities: `info` (advisory context only), `deprecation` (allowed today, spec recommends migration), `future_required` (optional today, required at a named future version with `effective_version`). +- Forward-compat: receivers MUST treat unknown `code` or `severity` values as well-formed and surface them verbatim — additive extensions ship without breaking older consumers, matching the same forward-compat rule the contract already applies to authored check kinds. + +Canonical first-day codes documented under `notice.canonical_codes`: +- `signed_requests_specialism_deprecated` (deprecation, motivated by the existing SHOULD in `signed-requests.yaml:34`). +- `request_signing_required_in_4_0` (future_required, `effective_version: 4.0`). +- `legacy_hmac_fallback_removed_in_4_0` (deprecation, `effective_version: 4.0`). + +`signed-requests.yaml` updated to reference the canonical code instead of the prose-only SHOULD. + +Files: +- `static/compliance/source/universal/runner-output-contract.yaml` — version bumped 2.1.0 → 2.2.0 (additive). New top-level `notice:` block defines required/optional fields and canonical codes. `step_result.optional_fields` and `run_summary.optional_fields` gain `notices`. +- `static/compliance/source/universal/signed-requests.yaml` — points the existing SHOULD at the new canonical `signed_requests_specialism_deprecated` code. + +SDK side (`@adcp/sdk`, `@adcp/client`) implements emission; tracked separately at adcp-client#1704. + +Refs #4418. diff --git a/.changeset/4796-review-followups.md b/.changeset/4796-review-followups.md new file mode 100644 index 0000000000..b2a2267f0f --- /dev/null +++ b/.changeset/4796-review-followups.md @@ -0,0 +1,34 @@ +--- +"adcontextprotocol": minor +--- + +spec: PR #4796 review follow-ups — close 7 footguns surfaced by detailed review. + +Consolidated fixes from a careful review of the 10-commit WG-review batch. None change the underlying decisions — they close gaps in the contract surface that careful adopters would have hit in production. + +**Polling / state re-read MUST mint a fresh `idempotency_key`.** The original §Idempotency guidance covered network-retry (reuse key) and agent-replan (new key) but was silent on polling reads and state re-reads. Under universal idempotency from 3.1, reusing a prior poll's key returns the cached snapshot for up to `replay_ttl_seconds` — the dashboard polling `get_products(brief)` or buyer agent reading `get_media_buys` after a mutation gets stale data, silently. Added a third case to the retry-vs-replan classification: **polling / state re-read** intent is "give me current state at time T," and MUST mint a fresh key per call. The same rule now governs the re-read step in the [Replay responses are historical snapshots] pattern — the re-read key MUST be fresh, never the mutation's key (which would return `IDEMPOTENCY_CONFLICT` or, worse, the cached mutation response). + +**Bootstrap carve-out for `get_adcp_capabilities`.** The fail-closed-on-missing-TTL rule deadlocked the bootstrap — the discovery call is *how* the buyer learns whether the seller declares `replay_ttl_seconds`. Made explicit: `get_adcp_capabilities` is exempt from rules 1–9; buyers MAY omit `idempotency_key` on the discovery call; sellers MUST accept the call without it. Fail-closed applies to every subsequent task request after the capability fetch. + +**Rate-limit ceilings flipped from "operators SHOULD revisit" to concrete read/write split.** A 3.1 agentic dashboard polling `get_products(brief)` + `list_creatives` + `list_accounts` across 5 accounts at 1Hz is ~15 inserts/sec on reads alone, before any write traffic. The original 60/sec sustained ceiling would silently rate-limit legitimate read polls. New recommended ceilings: **Reads 300/sec sustained / 1,500/sec burst, Writes 60/sec sustained / 300/sec burst, Combined cap 350/sec sustained / 1,700/sec burst**. The split-budget shape (separate counters) MUST be implemented from 3.1 onward even when operators tighten the magnitudes — a shared single-budget cap is the failure mode this rule prevents (a buyer's dashboard polling can't starve write capacity that protects `create_media_buy` / `sync_creatives` / `activate_signal`). + +**Multi-finalize atomicity contract clarified — observation point, not rollback.** "Atomic" was ambiguous: was the seller obligated to roll back if proposal A finalized but proposal B failed mid-commit? There's no `unfinalize` operation, so rollback was an unspecified obligation. Made explicit: atomicity runs on the pre-commit validation gate — sellers MUST NOT return success unless every named proposal has both completed and persisted; if any proposal fails validation, the seller MUST reject the entire call without committing any. Mid-commit failure (post-validation, pre-persist) MUST return `INTERNAL_ERROR` with `refinement_applied[]` per-position outcomes; recovery is undefined at the protocol level and buyers SHOULD re-read state before retrying. Buyer-intent caveat added: buyers whose intent specifically required atomic commit (budget-shared proposals) MUST be prepared to abandon the intent if the seller returns `MULTI_FINALIZE_UNSUPPORTED` — there is no recovery for that loss of intent beyond accepting the looser sequential-commit guarantee. + +**`context` envelope/body relationship documented.** 147 task request/response schemas already declare body-level `context` `$ref`'ing `core/context.json`. With #2911 adding `context` to the envelope, the field exists in two places. Under flat MCP serialization the two declarations occupy the same wire key — they're the same field, not a collision. Made explicit: envelope declaration is **authoritative**; per-task body declarations are mirrors retained for tooling reasons (SDK codegen completeness, per-task validation in isolation). Future versions MAY drop the body-level declarations; conformance does not require either to be present, only that the wire value `$ref`s `core/context.json`. + +**Forward-compat decoding ↔ Retry Logic symmetric cross-link.** The original cross-link was Forward-compat → Retry Logic (one direction). A reader landing on Retry Logic didn't see that the `transient` default for unknown codes was bounded there. Added a back-link paragraph at the top of `## Retry Logic`: "The rules in this section bound every `transient`-classified error, including the `transient` default applied to unknown error codes under § Forward-compatible decoding." + +**`pending_creatives` sharpening landed in `media-buys/index.mdx`.** The original #4196 fix landed only in the enum description on `media-buy-status.json`. Readers landing on the lifecycle doc at `docs/media-buy/media-buys/index.mdx` saw only the old "Approved but no creatives assigned" framing. Mirrored the buyer-side-action-required + `pending_X` naming convention into the lifecycle doc. + +**Strict-validator adopter-action row added.** The 7-row adopter-action table in 3.1.0 release notes didn't call out adopters with strict-validator test fixtures or codegen against `core/protocol-envelope.json`. Dropping `required: [status, payload]` is a JSON-Schema-level relaxation — strict validators that asserted "envelope MUST reject responses missing payload" will start accepting envelopes they used to reject. Added an 8th row noting the fixture refresh and codegen audit (OpenAPI / quicktype / Pydantic consumers will see `status` and `payload` flip from required to optional in generated types). + +Files: +- `docs/building/by-layer/L1/security.mdx` — polling/re-read paragraph (renamed "network retry vs. agent re-plan vs. polling / state re-read"), bootstrap carve-out for `get_adcp_capabilities`, split read/write ceilings in rule 8, fresh-key requirement in Replay responses section +- `docs/building/by-layer/L3/error-handling.mdx` — back-link paragraph at top of `## Retry Logic` +- `docs/media-buy/product-discovery/refinement.mdx` — atomicity-at-observation-point clarification, mid-commit failure paragraph, buyer-intent caveat +- `docs/media-buy/media-buys/index.mdx` — `pending_creatives` description mirrors the buyer-side-action sharpening +- `docs/reference/release-notes.mdx` — strict-validator adopter-action row (8th) +- `static/schemas/source/core/protocol-envelope.json` — `context` envelope/body relationship explained on the envelope declaration +- `static/schemas/source/media-buy/get-products-request.json` — refine[] description gains observation-point atomicity + mid-commit failure + buyer-intent caveat + +Refs PR #4796 review comments. No new behavior; closes gaps in the existing contract. diff --git a/.changeset/expert-review-followups.md b/.changeset/expert-review-followups.md new file mode 100644 index 0000000000..13530b3517 --- /dev/null +++ b/.changeset/expert-review-followups.md @@ -0,0 +1,42 @@ +--- +"adcontextprotocol": minor +--- + +spec: expert-review follow-ups on the 3.1 WG-review batch (#4399 / #4399b / #4107 / #4227 / #4371 / #2911). + +Consolidated fixes from four-expert review (ad-tech-protocol-expert, adtech-product-expert, security-reviewer, docs-expert) of the 9-commit WG-review batch on this branch: + +**Staged enforcement on universal idempotency_key.** Product expert flagged that a hard "MUST reject reads without idempotency_key" cliff at the 3.1 cut breaks hand-rolled integrators built via curl / thin MCP clients / OpenAPI codegen that doesn't include the field uniformly. Switched to staged: **3.1.0** sellers MUST accept reads carrying `idempotency_key` and SHOULD reject reads that omit it (MAY accept the omission during the 3.1.x maintenance window); **3.2.0** sellers MUST reject. SDK-using integrators (`@adcp/client`, `adcp-py`) are unaffected since both already send uniformly. + +**Cache-at-rest encryption (security reviewer M2).** Universal `idempotency_key` from 3.1 means the cache holds account-scoped read responses (`get_products`, `list_accounts`, `list_creatives`, `get_signals`), not just write receipts. Added: sellers MUST encrypt the cache tier at rest with the same controls used for the underlying resource store, MUST NOT treat the cache as a transient retry-receipt store exempt from data-at-rest controls, and MUST scope reads by `(authenticated_agent, account_id)` at the storage layer (not just application layer). + +**Forward-compatible decoding bounded by retry budget (security reviewer M1).** A receiver that literal-reads the new "default `transient` for unknown codes" rule and writes a retry loop without `maxRetries` could be DOS'd by a hostile sender emitting `code=GO_FOREVER, recovery=transient`. Added: the `transient` default is bounded by §Retry Logic (`maxRetries` + jittered exponential backoff); receivers MUST NOT loop indefinitely. Cross-link added from §Idempotency Buyer obligations to Forward-compatible decoding (the asymmetric link gap docs reviewer flagged). + +**Stale `replayed.description` (flagged by both protocol and docs reviewers).** `core/protocol-envelope.json` still said "Only present on responses to mutating requests that carry idempotency_key" — contradicts both the universal-idempotency change and the replay-snapshot rule. Updated to: "MAY appear on responses to any request that resolved via the idempotency cache, including read tools". + +**A2A serialization framing (protocol reviewer).** Envelope `notes` array described `task.artifacts[0].parts[].DataPart` and `task.status.message.parts[].DataPart` as symmetric, but `a2a-response-extraction.mdx` treats artifacts as canonical and `status.message.parts[]` as the fallback container only for interim states. Tightened to match the canonical/fallback framing and pinned the A2A version (0.3.0+). + +**Cache-growth ceiling acknowledgment (protocol reviewer optional).** Rule 8's recommended 60/sec sustained ceiling was sized against a write-heavy launch pattern. Added a note that read traffic now contributes under universal idempotency and operators with read-heavy mixes SHOULD revisit the deployed ceiling at the 3.1 cut rather than accept silent `RATE_LIMITED` of legitimate reads. The numeric recommendations remain the right starting *shape*, not the right starting *magnitude*, when reads dominate. + +**Mint `MULTI_FINALIZE_UNSUPPORTED` (protocol reviewer optional).** Protocol reviewer flagged that `INVALID_REQUEST` for a seller-side capability gap on multi-finalize ($refine[]$ atomicity) blurs "I can't support this combination" with "your request is malformed." Added `MULTI_FINALIZE_UNSUPPORTED` as the preferred code (`recovery: correctable`); `INVALID_REQUEST` remains acceptable for sellers on pre-3.1 error catalogs. + +**3.1.0 release-notes — `Wire conformance` section + adopter-action table (docs reviewer).** The reach_window section was the only 3.1.0 entry; the spec changes from the WG-review batch were invisible to a 3.0→3.1 migrator reading release-notes. Added a new `### Wire conformance — idempotency & envelope tolerance` section covering all 8 spec changes plus an adopter-action table for the seven distinct integrator categories (SDK-using buyers, hand-rolled MCP clients, FastMCP/Pydantic/Zod sellers, sellers with synchronous-success state-tracking responses, agentic buyers reading `status` from mutations, sellers emitting unsigned webhooks or deprecated specialism claims, sellers emitting unknown error codes). + +**`get_adcp_capabilities.mdx` idempotency block (docs reviewer consistency).** Stale "for mutating requests" framing on the capability description updated to reference the staged universalization and link to security.mdx. + +**3.0→3.1 sender audit note (protocol reviewer optional).** `error.recovery` MUST-populate-from-3.1 rule is safe for buyers (they default to `transient` when absent) but sellers ratcheting `adcp_version` to 3.1 with un-audited error-emit code paths are non-conformant. Adopter-action table calls this out explicitly. + +Files: +- `docs/building/by-layer/L1/security.mdx` — enforcement-curve paragraph, cache-at-rest paragraph, retry-bounded cross-link, rule-8 read-traffic acknowledgment +- `docs/building/by-layer/L3/error-handling.mdx` — `transient`-default bounded-by-retry sentence, `MULTI_FINALIZE_UNSUPPORTED` table row +- `docs/building/operating/transport-errors.mdx` — `MULTI_FINALIZE_UNSUPPORTED` recovery row +- `docs/protocol/get_adcp_capabilities.mdx` — idempotency block updated for staged universalization +- `docs/reference/release-notes.mdx` — new `### Wire conformance` section under 3.1.0 with adopter-action table +- `docs/media-buy/product-discovery/refinement.mdx` — `MULTI_FINALIZE_UNSUPPORTED` referenced, error table row added +- `docs/media-buy/task-reference/get_products.mdx` — `MULTI_FINALIZE_UNSUPPORTED` error table row +- `static/schemas/source/core/protocol-envelope.json` — `replayed.description` rewritten, A2A serialization framing tightened +- `static/schemas/source/enums/error-code.json` — `MULTI_FINALIZE_UNSUPPORTED` added to enum / enumDescriptions / enumMetadata (recovery: correctable) +- `scripts/error-code-drift-dispositions.json` — `MULTI_FINALIZE_UNSUPPORTED` held-for-next-minor / 3.1 +- `static/schemas/source/media-buy/get-products-request.json` — multi-finalize description references the preferred code + +Refs the eight prior commits in this WG-review batch. diff --git a/docs/building/by-layer/L0/mcp-guide.mdx b/docs/building/by-layer/L0/mcp-guide.mdx index dbdda752f7..06bcc71351 100644 --- a/docs/building/by-layer/L0/mcp-guide.mdx +++ b/docs/building/by-layer/L0/mcp-guide.mdx @@ -62,26 +62,30 @@ console.log(response.context); // { ui: 'buyer_dashboard', session: '123' } ## MCP Response Format -**New in AdCP 1.6.0**: All responses include unified status field. - -MCP responses use a **flat structure** where task-specific fields are at the top level alongside protocol fields: +**Normative:** AdCP MCP responses use a **flat structure** — envelope fields (`status`, `context_id`, `context`, `task_id`, `timestamp`, `replayed`, `adcp_error`, `governance_context`) and task-body fields appear as siblings at the root of the tool response. The `payload` object defined on [`core/protocol-envelope.json`](https://adcontextprotocol.org/schemas/v3/core/protocol-envelope.json) is a documentary grouping construct, NOT a serialized wire key: body fields are NOT nested under a `payload:` key on MCP. This matches MCP's native `structuredContent` convention. ```json { - "status": "completed", // Unified status (see Core Concepts) - "message": "Found 5 products", // Human-readable summary - "context_id": "ctx-abc123", // MCP session continuity - "context": { "ui": "buyer_dashboard" }, // Application-level context echoed back - "products": [...], // Task-specific data (flat, not nested) - "errors": [...] // Task-level errors/warnings + "status": "completed", // envelope: unified task status + "message": "Found 5 products", // envelope: human-readable summary + "context_id": "ctx-abc123", // envelope: session identifier (server-managed) + "context": { "ui": "buyer_dashboard" }, // envelope: per-request opaque echo (caller-owned) + "timestamp": "2026-05-19T14:25:30Z", // envelope: response generation time + "products": [...], // body: task-specific data, sibling of envelope fields + "errors": [...] // body: per-record / payload-level errors (warning severity allowed) } ``` -### MCP-Specific Fields -- **context_id**: Session identifier that you must manually manage -- **context**: Opaque initiator-provided metadata echoed by agents -- **status**: Same values as A2A protocol for consistency -- Task-specific fields (e.g., `products`, `media_buy_id`, `creatives`) are at the top level, not wrapped in a `data` object +**Producer rule.** MCP tool implementations MUST emit envelope fields and body fields as flat siblings at the root. Nesting body fields under a `payload:` key is non-conformant — receivers parse from the flat root, and a nested representation breaks every shipping SDK. + +**Receiver rule.** MCP tool consumers MUST parse envelope and body fields from the flat root of the tool response. Receivers MUST NOT require a nested `payload:` key; the schema's `payload` is documentation, not a wire requirement. When `status` is absent on the response (legacy or transport-native state carrier), receivers MUST default to `completed` for non-error responses and inspect `adcp_error` for error envelopes. + +**`context_id` vs `context` — semantically orthogonal.** +- `context_id` is a **server-managed session identifier** for tracking related operations across multiple tool invocations. The server issues it; the caller MAY echo it on subsequent calls to thread a session. Distinct from MCP's transport-level session. +- `context` is a **caller-supplied opaque echo object** ([`core/context.json`](https://adcontextprotocol.org/schemas/v3/core/context.json)) — the agent preserves it byte-for-byte without parsing. Used for buyer-side correlation (UI session IDs, trace IDs, custom metadata). +- Both MAY appear on the same response. They are NOT aliases. + +**Status handling**: see [Task Lifecycle](/docs/building/by-layer/L3/task-lifecycle) for complete status handling patterns. **Status Handling**: See [Task Lifecycle](/docs/building/by-layer/L3/task-lifecycle) for complete status handling patterns. @@ -741,6 +745,16 @@ const products = await handleAdcpCall('get_products', { ## MCP-Specific Considerations +### Server-side tool wrappers MUST tolerate envelope fields + +Buyer SDKs send envelope-level fields (`idempotency_key`, `context_id`, `context`, `governance_context`, `push_notification_config`) uniformly across all AdCP tool calls — including read-only tools that don't consume them. MCP tool implementations MUST accept these fields and ignore the ones they don't use; they MUST NOT reject a call because an envelope field is present. Common traps: + +- **FastMCP / Pydantic strict signatures** — declare `idempotency_key: str | None = None` (and the other envelope fields) as accept-and-ignore optionals, or use `**kwargs` to swallow unknowns. `model_config = ConfigDict(extra='allow')` on input models if you control them. +- **Zod / valibot with `.strict()`** on input schemas — drop `.strict()` or use a passthrough variant. +- **OpenAPI codegen** that injects `additionalProperties: false` into input models — fix the generator config; the spec's request schemas declare `additionalProperties: true`. + +A wrapper that raises `unexpected_keyword_argument` on `idempotency_key` will fail compliance against any buyer SDK that follows the envelope contract. See [security.mdx > Server-side tool wrapper conformance](/docs/building/by-layer/L1/security#server-side-tool-wrapper-conformance) for the normative rule. + ### Tool Discovery ```javascript // List available tools — use get_adcp_capabilities for runtime feature detection diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index 74f7cc8b5d..6e61a6fdb4 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -288,14 +288,28 @@ A daypart with no declared semantics is ambiguous and MUST be rejected with `INV ### Idempotency -`idempotency_key` is **required** on every mutating AdCP task request (`create_media_buy`, `update_media_buy`, `sync_creatives`, `activate_signal`, `acquire_rights`, `creative_approval`, `update_rights`, `build_creative`, `calibrate_content`, `create_content_standards`, `update_content_standards`, `create_property_list`, `update_property_list`, `delete_property_list`, `create_collection_list`, `update_collection_list`, `delete_collection_list`, `log_event`, `provide_performance_feedback`, `report_usage`, `report_plan_outcome`, `si_initiate_session`, `si_send_message`, and the `sync_*` tasks). Sellers MUST reject any mutating request that omits it with `INVALID_REQUEST`. Keys are scoped per `(authenticated agent, account)` — they have no meaning across agents on the same seller, across accounts under the same agent, or across sellers. Scoping by both dimensions prevents cross-account cache collisions when one agent (e.g. an agency) acts on multiple accounts: an identical-looking `create_media_buy` under account A and account B is two distinct buys, never one cached response replayed across the two. +`idempotency_key` is **required on every AdCP task request** — read and mutating alike. Keys are scoped per `(authenticated agent, account)` — they have no meaning across agents on the same seller, across accounts under the same agent, or across sellers. Scoping by both dimensions prevents cross-account cache collisions when one agent (e.g. an agency) acts on multiple accounts: an identical-looking `create_media_buy` under account A and account B is two distinct buys, never one cached response replayed across the two. + +**Enforcement curve.** Sellers MUST reject any **mutating** request that omits `idempotency_key` with `INVALID_REQUEST` from 3.0 onward (unchanged). For **read** requests, the rule phases in across two minors: + +- **3.1.0** — sellers MUST accept reads that carry `idempotency_key` and process per rules 2–9 (no rejecting on undeclared envelope fields). Sellers SHOULD reject reads that omit it with `INVALID_REQUEST`; sellers MAY accept the omission for the 3.1.x maintenance window. +- **3.2.0** — sellers MUST reject reads that omit `idempotency_key` with `INVALID_REQUEST`. The grace window closes at the 3.2 cut. + +This staged enforcement lets hand-rolled buyer integrations — built via curl, thin MCP clients, or OpenAPI codegen that doesn't include the field uniformly — migrate over a release window rather than at the 3.1 cut. Buyer SDKs (`@adcp/client`, `adcp-py`) already send `idempotency_key` uniformly today, so SDK-using integrators are unaffected by the cut date. + +**Why universal — including read tools.** Several AdCP tasks are polymorphic. `get_products` is the canonical case: `buying_mode: 'brief'` / `'wholesale'` may complete synchronously (pure read), but the same tool MAY return a `Submitted` envelope when curation requires upstream queries or HITL, and `buying_mode: 'refine'` with `action: 'finalize'` is a commit that transitions a proposal to committed with an `expires_at` hold window (see [refinement guide § Finalize is exclusive](/docs/media-buy/product-discovery/refinement)). Buyers cannot predict at call time whether a given call will be a pure read, an async-task creation, or a commit — so the wire contract requires `idempotency_key` on every call uniformly. For calls that resolve as pure reads, the cache provides byte-stable replay-on-retry within the TTL, which is harmless and gives buyers a uniform retry-safe contract; for calls that resolve as async-task creation or commit, the cache provides the same at-most-once guarantees as on mutating tasks. The alternative — classifying per-call read-vs-mutating in the buyer's SDK — is not feasible when the same task name has both read and write modes. Decoding unknown `error.code` values returned by sellers (whether `INVALID_REQUEST` during the grace window or codes added in later minors) follows the [Forward-compatible decoding](/docs/building/by-layer/L3/error-handling#forward-compatible-decoding-normative) rule. This section applies only to AdCP task requests. OpenRTB bid streams have their own semantics (`BidRequest.id` is a transaction ID, not an idempotency key) and are out of scope. #### Normative seller behavior 1. **Schema validation runs first.** Sellers MUST validate the request against its schema (including presence and format of `idempotency_key`) BEFORE consulting the idempotency cache. A malformed request returns `INVALID_REQUEST` without ever touching the cache — otherwise cache misses become a timing side channel that leaks whether schema validation accepted the key format. Validation errors are never cached (per rule 2). -2. **First call is canonical.** On **task success** (`status: completed` or `status: submitted` for async operations), the seller stores the inner response payload (not the protocol envelope) keyed by `(authenticated_agent, account_id, idempotency_key)` along with a hash of the canonical request payload. For async tasks, the cached response is the `submitted` result containing `task_id`. **The cache entry is immutable** — even if the async task subsequently completes, fails, or is canceled, a replay within the TTL MUST return the originally-cached `submitted` response (with `replayed: true`), NOT the current terminal state. The buyer uses the returned `task_id` to observe current state via `tasks/get` or webhook, exactly as it would have on the first call. This preserves the byte-stable cache property and keeps the idempotency layer decoupled from async task lifecycle — sellers don't need to update cache entries when task state changes. +2. **First call is canonical.** On **task success** (`status: completed` or `status: submitted` for async operations), the seller stores the inner response payload (not the protocol envelope) keyed by `(authenticated_agent, account_id, idempotency_key)` along with a hash of the canonical request payload. **The cache entry is immutable** — replays within the TTL MUST return the originally-cached payload (with `replayed: true`), and state-tracking fields in that payload MUST NOT be refreshed to reflect the resource's current state. This rule applies across both success branches: + + - **Async tasks** — the cached response is the `submitted` result containing `task_id`. Even if the async task subsequently completes, fails, or is canceled, a replay MUST return the originally-cached `submitted` response, NOT the current terminal state. The buyer uses the returned `task_id` to observe current state via `tasks/get` or webhook, exactly as it would have on the first call. + - **Synchronous-success tasks** — when the initial response carries state-tracking fields (e.g., `status`, `packages`, `affected_packages` on `create_media_buy`; per-record `status` arrays on `sync_creatives` / `sync_accounts`; resource snapshots on `acquire_rights` / `activate_signal`), replay MUST return the originally-cached payload regardless of intervening mutations to the resource. A media buy that was created with `status: pending_creatives`, then mutated to `canceled` via `update_media_buy`, replays as `status: pending_creatives` — the cached bytes are a historical snapshot of the create-time response, not a current-state read. Buyers MUST consult the resource's read endpoint (`get_media_buys`, `list_accounts`, `list_creatives`, etc.) for current state; see "Buyer obligations" below. + + This preserves the byte-stable cache property uniformly and keeps the idempotency layer decoupled from resource lifecycle — sellers don't need to update cache entries when task or resource state changes. The alternative ("refresh state fields on replay") would force every seller to thread the resource state machine through the idempotency cache, multiply the number of valid cache contents for a given key (a single key's replay would no longer be deterministic across calls), and break the canonical-replay invariant the rest of these rules build on. Sellers MUST NOT implement a hybrid where some state-tracking fields refresh on replay and others do not — partial refresh is the worst of both options and is non-conformant. 3. **Only successful responses are cached.** On any error — validation, governance denial, transport failure, internal error — the key is **not** stored. A retry re-executes. This matches buyer intent: a retry after a 5xx should try again, not replay a failure. It also prevents a buyer's malformed request from being locked into a key for its full TTL. 4. **Replay returns the cached response.** A subsequent request with the same `idempotency_key` AND an equivalent canonical-form payload (see "Payload equivalence" below) MUST return the stored inner response without re-executing side effects. The seller injects `replayed: true` onto the outgoing protocol envelope at response time — `replayed` is an envelope-level field produced by the idempotency layer, NOT part of the cached inner response. Injection at replay time keeps the cached payload byte-stable across replays regardless of envelope changes (new `timestamp`, rotated `governance_context`, etc.). Transport-specific note for MCP: MCP tool responses do not have a separate envelope slot; servers MAY expose `replayed` inside the tool result object itself (e.g., at the top of the structured return) or via a response metadata field. REST and A2A responses use the envelope field directly. 5. **Key reuse with a different canonical payload is a conflict.** Same key, different canonical hash within the replay window MUST be rejected with `IDEMPOTENCY_CONFLICT`. Sellers MUST NOT silently apply the second request. @@ -305,7 +319,13 @@ This section applies only to AdCP task requests. OpenRTB bid streams have their 7. **Replay window is declared, not inferred.** Sellers MUST declare `capabilities.idempotency.replay_ttl_seconds` on `get_adcp_capabilities` (minimum 3600s / 1h, recommended 86400s / 24h, maximum 604800s / 7d). Clients MUST NOT fall back to an assumed default — a seller with no declaration is non-compliant and MUST be treated as unsafe for retry-sensitive operations. 8. **Cache-growth defense.** Sellers MUST apply per-`(authenticated_agent, account)` rate limits on idempotency cache inserts separately from request rate limits, and MUST return `RATE_LIMITED` (see [error taxonomy](/docs/building/by-layer/L3/error-handling#rate-limit-handling)) when the per-agent insert rate exceeds the configured ceiling rather than let the cache grow unbounded. A buyer submitting N fresh keys per second on a cheap success-path operation (e.g., `log_event`) would otherwise force unbounded storage, with amplification proportional to `replay_ttl_seconds` at the 3600 s floor. The natural bound is `inserts_per_hour × replay_ttl_hours ≤ max_cache_rows_per_agent`. - **Recommended ceiling: 60 inserts/sec per agent sustained (3,600/min), with burst allowance up to 300 inserts/sec over rolling 10-second windows.** The sustained bound is a rolling 60-second window — a burst that empties a 10-second window still counts toward the next 50 seconds of the 60-second rolling bound. Sellers that adopt a different window shape (fixed-minute bucket, EWMA) MUST document it so buyers with retry logic can predict when `RATE_LIMITED` fires; silent window-shape divergence between sellers means identical buyer traffic passes one seller and is rejected by another on conformant implementations. At the 3600 s TTL floor the recommended rates bound per-agent residency to ~216,000 entries — the same order of magnitude as the 100,000-entry per-keyid webhook replay cap at [Webhook replay dedup sizing](#webhook-replay-dedup-sizing), and an order of magnitude below the 1,000,000-entry per-keyid request-replay cap at [Transport replay dedup](#transport-replay-dedup). The numeric recommendations are SHOULD-level; the rate-limit-and-reject-with-`RATE_LIMITED` behavior itself is MUST. Sellers MUST expose the ceiling as a tunable configuration parameter — the 60/300/3,600 values are first-deployment starting points sized for a realistic high-volume launch pattern (≤10 media buys/min × 10 packages × 10 creatives, with 3–5× headroom for multi-campaign and retry patterns), not frozen defaults. Operators with burst onboarding or trafficking patterns larger than this ceiling MUST raise the limit rather than accept silent rejection of legitimate traffic; operators with steady low-volume traffic MAY tighten below the starting values. Sellers SHOULD NOT publish their exact configured ceiling numerically in capability responses — doing so makes the ceiling an ecosystem-wide attack target. Buyers discover the effective ceiling through the `RATE_LIMITED` + `retry_after` response, not through capability introspection. + **Recommended ceilings (3.1+):** the original 60/sec sustained / 300/sec burst single-budget ceiling was sized against a write-heavy launch pattern (≤10 media buys/min × 10 packages × 10 creatives with 3–5× headroom). Under universal idempotency, read traffic also contributes to insert rate — a single agentic dashboard polling `get_products(brief)` + `list_creatives` + `list_accounts` across 5 accounts at 1Hz is ~15 inserts/sec on reads alone, before any write activity. Operators SHOULD adopt a **split budget** per `(authenticated_agent, account)`: + + - **Reads: 300 inserts/sec sustained, 1,500/sec burst over rolling 10s windows.** Dominated by dashboard polling and agentic state re-reads under the [Polling / state re-read](#agent-retry-vs-polling-vs-re-plan) rule. Read traffic is typically bursty during user-driven UI interactions and steady at low rates during agent runs. + - **Writes: 60 inserts/sec sustained, 300/sec burst.** Unchanged from the original write-heavy sizing — preserved as a separate budget so a buyer's dashboard polling can't exhaust the write capacity that protects `create_media_buy` / `sync_creatives` / `activate_signal` from double-execution races. + - **Combined cap (defense in depth):** total inserts SHOULD NOT exceed 350/sec sustained / 1,700/sec burst per agent — the sum of the two budgets with a small cushion, so an attacker who saturates the read budget cannot starve write capacity. + + Operators with steady low-volume traffic MAY tighten below these starting values; operators with burst onboarding or trafficking patterns larger than this ceiling MUST raise rather than accept silent rejection of legitimate traffic. The split-budget shape (separate read and write counters) MUST be implemented from 3.1 onward even when operators tighten the magnitudes — a shared single-budget cap is the failure mode this rule prevents. The sustained bound is a rolling 60-second window — a burst that empties a 10-second window still counts toward the next 50 seconds of the 60-second rolling bound. Sellers that adopt a different window shape (fixed-minute bucket, EWMA) MUST document it so buyers with retry logic can predict when `RATE_LIMITED` fires; silent window-shape divergence between sellers means identical buyer traffic passes one seller and is rejected by another on conformant implementations. At the 3600 s TTL floor the combined-cap rates bound per-agent residency to ~1.26M entries — an order of magnitude above the original 216k from the write-only sizing, reflecting the read-traffic addition; per-agent storage budgeting should account for this. The numeric recommendations are SHOULD-level; the rate-limit-and-reject-with-`RATE_LIMITED` behavior itself is MUST. Sellers MUST expose the ceilings as tunable configuration parameters — the 300/60 read/write split numbers are first-deployment starting points for an agentic-buyer dashboard pattern, not frozen defaults. Sellers SHOULD NOT publish exact configured ceiling numerics in capability responses — doing so makes the ceiling an ecosystem-wide attack target. Buyers discover the effective ceilings through the `RATE_LIMITED` + `retry_after` response, not through capability introspection. The ceiling is per `(authenticated_agent, account)` — the same scope as the idempotency key itself (bullet 1) — so a multi-account agency does not have its per-account budgets collapsed into a single shared quota. `RATE_LIMITED` rejections MUST populate `retry_after` (seconds) per the [error handling taxonomy](/docs/building/by-layer/L3/error-handling#rate-limit-handling) and MUST NOT be cached as idempotency responses (rule 3: only successful responses are cached). Sellers SHOULD enforce `retry_after` as a cheap rejection floor — a buyer retrying before `retry_after` elapses SHOULD hit a pre-auth token bucket (e.g., at a reverse-proxy layer) rather than re-entering the full schema-validate-and-cache-check pipeline on every retry. Without this discipline, misbehaving buyers can amplify load on the rate-limiter itself. @@ -361,9 +381,26 @@ Everything else in the request body — including `ext` — is included, and "mi AdCP SDK middleware ships JCS canonicalization so sellers don't roll their own. Rolling your own canonical form is a common source of "works on my machine" idempotency bugs — JCS is precisely specified to avoid that. +#### Server-side tool wrapper conformance + +Buyer SDKs send envelope-level fields (`idempotency_key`, `context_id`, `context`, `governance_context`, `push_notification_config`) **uniformly across all AdCP tool calls** — buyers cannot know per-tool which envelope fields the seller's wrapper happens to declare. Servers MUST tolerate envelope-level fields that arrive in tool params but are not declared in the tool's parameter schema. Concretely: + +- **`idempotency_key`** is required on every AdCP task request (see rule 1 above — read and mutating alike). Tool wrappers MUST accept it; the idempotency layer routes it per rules 2-9. Wrappers that reject the field with `unexpected_keyword_argument` (FastMCP/Pydantic strict signatures) are non-conformant. +- **`context_id`, `context`, `push_notification_config`, `governance_context`** MUST be accepted on every tool, including reads. Tools that don't consume a given field MUST ignore it; they MUST NOT reject the call because the envelope field is present. + +This is the server-side counterpart to the `additionalProperties: true` default that every published AdCP request schema declares. Configuring a server-side validator in a way that contradicts the schema's own `additionalProperties` declaration is a conformance violation. Common server-implementation traps: + +- **FastMCP / Pydantic with strict signatures** — a tool wrapper declared as `def get_products(brief: str)` raises `unexpected_keyword_argument` when the buyer sends `idempotency_key` inside the same params object. Fix: declare `idempotency_key: str | None = None` (and the other envelope fields) as accept-and-ignore optional parameters, or use a `**kwargs` catch-all and discard unknown keys. Pydantic-on-input uses `Extra.allow` or `model_config = ConfigDict(extra='allow')`. +- **Zod / valibot with `.strict()`** on the inbound request schema rejects unknown keys for the same reason; remove `.strict()` on input schemas, or compose with a passthrough variant. +- **OpenAPI-generated server stubs** with `additionalProperties: false` injected by the codegen tool — verify the generated input schema mirrors the spec's `additionalProperties: true` default; some generators flip the default during model emission. + +The wire-level invariant is: a buyer SDK MUST be able to send the same envelope-field set to every AdCP tool on every seller, and any seller that rejects on envelope fields breaks the cross-seller portability the protocol promises. This rule is normative for 3.1+; pre-existing wrappers that reject envelope fields are non-conformant at the next maintenance bump. + +Reference: this rule generalizes the per-validator pattern already established for response-side validators in [`runner-output-contract.yaml` > `response_schema_validator_semantics`](https://github.com/adcontextprotocol/adcp/blob/main/static/compliance/source/universal/runner-output-contract.yaml) — both rules express the same principle ("validator configuration MUST NOT contradict the schema's own `additionalProperties` declaration") on the two ends of the wire. + #### Response-level replay indicator -The protocol envelope carries a top-level `replayed` boolean on responses to mutating requests: +The protocol envelope carries a top-level `replayed` boolean on responses to any request that resolved via the idempotency cache: ```json { @@ -384,6 +421,7 @@ Buyers use `replayed` for: - **Side-effect invariants** — downstream systems expecting exactly-once event semantics read `replayed` before treating the response as a new event. - **Billing reconciliation** — "we processed N buys this month" counts `replayed: false` only. - **Logging** — distinguishing "retry succeeded by returning cache" from "retry triggered a new execution" (the latter usually signals a bug in the replay window or key management). +- **State-machine routing** — state-tracking fields in the cached `payload` (e.g., `status: pending_creatives` on a replayed `create_media_buy`) are a historical snapshot, not a current-state read (see seller rule 2 and "Replay responses are historical snapshots" under buyer obligations). Buyers MUST re-read via the resource's read endpoint before any state-dependent action. #### IDEMPOTENCY_CONFLICT response shape @@ -419,19 +457,30 @@ Leaking cached state turns key-reuse into a read oracle. An attacker who guesses Buyers MUST generate a unique `idempotency_key` per `(seller, request)` pair. Reusing the same key across sellers allows colluding sellers to correlate requests from the same buyer. Use a fresh UUID v4 for each request. On retry after a network error, buyers MUST resend the exact same payload with the same key — changing either side breaks at-most-once semantics. In particular, buyers MUST NOT change `push_notification_config.url` between retries with the same key; URL is part of the canonical hash and rotating it triggers `IDEMPOTENCY_CONFLICT`. Rotate the key when changing webhook configuration. -**Agent retry vs. network retry.** Two cases that look similar but need opposite handling: +**Network retry vs. agent re-plan vs. polling / state re-read.** Three cases that look similar but need different handling: - **Network retry** — socket timeout, 5xx, transient failure. The buyer has the *same intent* and sent the *same bytes* — and MUST resend them with the *same key*. This is what idempotency_key exists for. - **Agent re-plan** — the buyer is an agent whose planner re-ran (prompt re-executed, tool output changed, policy re-evaluated) and produced a *different payload*. The intent has changed. The agent MUST mint a *new key* and treat the prior request as abandoned. Reusing the prior key with a different canonical payload returns `IDEMPOTENCY_CONFLICT`, which is the seller correctly telling the agent "you're not retrying, you're doing something new." +- **Polling / state re-read** — a dashboard polling `get_products(brief)`, `list_creatives`, `list_accounts` at intervals; a buyer agent reading `get_media_buys` to fetch fresh state after a mutation; any "give me current state at time T" call. Buyers MUST mint a fresh `idempotency_key` per call. Reusing the prior poll's key would replay the cached snapshot (up to `replay_ttl_seconds`), silently returning stale data — exactly the failure mode the cache exists to prevent on mutations. This rule also governs the re-read step in the [Replay responses are historical snapshots](#replay-responses-are-historical-snapshots) pattern below: the "re-read for current state" call MUST carry a fresh key, never the key from the mutation it's reading state for. -When in doubt, ask whether the serialized request bytes are the same. If yes, reuse the key. If no, the intent changed — mint a new key. Agentic clients that loop through an LLM to build the request SHOULD freeze and cache the serialized bytes alongside the key on first send, so retries send the identical payload even if the planner would produce something slightly different on re-execution. +When in doubt, ask whether the buyer's intent is **"give me the same answer as before"** (network retry — reuse the key) or **"give me the current answer"** (polling / state re-read — mint a new key) or **"do this new thing"** (agent re-plan — mint a new key). Agentic clients that loop through an LLM to build the request SHOULD freeze and cache the serialized bytes alongside the key on first send for the network-retry case, so retries send the identical payload even if the planner would produce something slightly different on re-execution. -**When the seller's capability declaration is missing.** A seller that omits `adcp.idempotency.replay_ttl_seconds` from `get_adcp_capabilities` is non-compliant. Client SDKs MUST fail closed on retry-sensitive operations against that seller — raise an error, don't assume a default — so the buyer learns about the non-compliance immediately rather than after a silent double-booking. Read-only operations (`get_products`, `list_accounts`, etc.) are safe to issue against such a seller; only mutating requests require the declaration. +**Bootstrap carve-out — `get_adcp_capabilities`.** The discovery call itself is exempt from rules 1–9 of this section. `get_adcp_capabilities` is how the buyer learns whether the seller declares `adcp.idempotency.replay_ttl_seconds`, so a fail-closed rule against the discovery call would deadlock the bootstrap. Buyers MAY omit `idempotency_key` on `get_adcp_capabilities`, and sellers MUST accept the call without it. Buyers that send `idempotency_key` on `get_adcp_capabilities` (e.g., SDKs that include the field uniformly) get the standard cache behavior — but the discovery call carries no state and replay is harmless. Every other AdCP task request remains subject to rules 1–9; the fail-closed obligation below applies once the capability fetch has completed. + +**When the seller's capability declaration is missing.** A seller whose `get_adcp_capabilities` response omits `adcp.idempotency.replay_ttl_seconds` is non-compliant. After a successful capability fetch, client SDKs MUST fail closed on every subsequent AdCP task request against that seller — raise an error, don't assume a default — so the buyer learns about the non-compliance immediately rather than after a silent double-booking. The fail-closed rule applies to every AdCP task request (other than `get_adcp_capabilities` itself) now that `idempotency_key` is required universally — including calls that resolve as pure reads, because the buyer cannot predict at call time whether a polymorphic task (`get_products` brief vs. refine+finalize vs. async-Submitted) will resolve as a read or a mutation, and the missing TTL declaration means the seller is unsafe to retry against in any mode. + +**Decoding seller-emitted error codes.** Sellers MAY return error codes (`IDEMPOTENCY_CONFLICT`, `IDEMPOTENCY_EXPIRED`, `IDEMPOTENCY_IN_FLIGHT`, `INVALID_REQUEST`, or codes added in later minor versions) that buyers' pinned vocabulary may not recognize. Receivers MUST decode these per [Forward-compatible decoding (normative)](/docs/building/by-layer/L3/error-handling#forward-compatible-decoding-normative) — read `error.recovery` for the recovery classification, default to `transient` when `recovery` is absent, and never reject the response because the code value is unfamiliar. The retry semantics for `transient`-classified errors are bounded by [§ Retry Logic](/docs/building/by-layer/L3/error-handling#retry-logic) (`maxRetries` and exponential backoff with jitter) — buyers MUST NOT loop indefinitely on a `transient` default. + +**Replay responses are historical snapshots.** A response carrying `replayed: true` is byte-equivalent to the original first-call response (per seller rule 2) — state-tracking fields in it reflect the resource's state at first-call time, NOT the resource's current state. A buyer that reads `status: pending_creatives` from a replayed `create_media_buy` response and then calls `update_media_buy(canceled: true)` on a resource that has actually been in `canceled` for hours will surface a `NOT_CANCELLABLE` error and a state-machine bug. Buyers requiring current state MUST consult the resource's read endpoint — `get_media_buys` for media buys, `list_accounts` for accounts, `list_creatives` for creatives, `get_signals` for signals, equivalents for other resources. `replayed: true` is the explicit signal that a fresh read is required before any state-dependent decision; SDKs SHOULD surface the flag to caller code rather than transparently unwrap it. Agentic buyers MUST treat `replayed: true` as a stop signal for any planning step whose next action depends on resource state, and MUST re-read before continuing. + +**The re-read MUST carry a fresh `idempotency_key`.** Reusing the key from the mutation whose state you're re-reading either returns `IDEMPOTENCY_CONFLICT` (if the read payload differs from the mutation payload — almost always true) or, worse, returns the cached mutation response itself (if the payloads happen to match). Reusing a *prior read's* key returns that prior read's cached snapshot — the exact stale-state failure mode this rule exists to prevent. State re-reads fall under the Polling / state re-read case above; mint a new key per call. **TTL boundary for persisted keys.** Some buyers persist `idempotency_key` alongside their own object (e.g., `campaign.pending_idempotency_key` in the buyer's DB) so that retries after a process restart or overnight reconcile still dedup. This works **only within the seller's declared `replay_ttl_seconds`**. Beyond the TTL, the seller will either reject the retry with `IDEMPOTENCY_EXPIRED` (good) or, if the cache was evicted, treat it as a new request (silent double-booking — the failure mode this field exists to prevent). Buyers retrying past the TTL MUST fall back to a natural-key check (e.g., query `get_media_buys` by `context.internal_campaign_id`) before resending. The `idempotency_key` guarantees at-most-once execution within the replay window, not forever. Queue-based retry systems and workflow engines with retry horizons longer than the seller's TTL MUST be designed around this — don't put a key into a dead-letter queue that replays days later without a natural-key re-check. **Keys are security-sensitive.** An `idempotency_key` is a secret capability token within its TTL — anyone who holds one and knows the original payload can replay it and read the cached response. Treat keys the way you treat session tokens: do not log them in full, do not embed them in URLs, do not share them across agents. Log prefix-only (first 8 chars of the UUID) if you need correlation. Buyers persisting `pending_idempotency_key` at rest (e.g., alongside a campaign row in the buyer's DB) MUST encrypt it with the same controls used for bearer tokens, and SHOULD purge the key after success confirmation to minimize the exposure window. +**Sellers MUST encrypt the cache tier at rest.** Under universal idempotency (3.1+), the cache holds read-tool responses (`get_products`, `list_accounts`, `list_creatives`, `get_signals`, etc.) in addition to the write receipts it held in 3.0.x. Those read responses carry account-scoped data — brand domains, account names, product allocations, signal references — at the same sensitivity as the seller's underlying resource store. Sellers MUST apply at-rest encryption to the idempotency cache with the same controls used for the resource store the cached data was read from, MUST NOT treat the cache as a transient retry-receipt store exempt from data-at-rest controls, and MUST scope cache reads by `(authenticated_agent, account_id)` at the storage layer (not just at the application layer) so a misconfigured query cannot pull a sibling tenant's cached read response. + **Keys MUST be unguessable.** Schema enforces `^[A-Za-z0-9_.:-]{16,255}$` and buyers MUST use UUID v4 (~122 bits of entropy) or an equivalent CSPRNG-generated value. Low-entropy keys like `retry-001` or monotonic counters turn the cache into an enumerable surface: an attacker can walk the key space and test each one against a target agent. Sellers SHOULD reject keys that fail a basic entropy check (e.g., all-zeros, repeated characters, short ASCII words) with `INVALID_REQUEST` when the authenticated agent is not individually trusted. **The three-state response (`success` / `IDEMPOTENCY_CONFLICT` / `IDEMPOTENCY_EXPIRED`) is an existence oracle for idempotency keys.** An attacker who holds a candidate key can probe it: `success` means never seen, `IDEMPOTENCY_CONFLICT` means live with a different payload, `IDEMPOTENCY_EXPIRED` means previously used. The per-`(agent, account)` scoping above is the primary defense — an attacker authenticated as agent A cannot probe agent B's keys, and a caller scoped to account A cannot probe account B's keys even under a shared agent credential. Unguessable keys are the secondary defense — an attacker who cannot guess a victim's key cannot probe the oracle usefully. Sellers MUST NOT surface `IDEMPOTENCY_EXPIRED` across scope boundaries or to unauthenticated callers. Sellers SHOULD also avoid distinguishable timing between "key exists" and "key does not exist" lookups in the idempotency layer; a constant-time floor on the negative path closes a side channel that persists even without an error-code oracle. diff --git a/docs/building/by-layer/L3/error-handling.mdx b/docs/building/by-layer/L3/error-handling.mdx index 72458ac3a8..aeff120582 100644 --- a/docs/building/by-layer/L3/error-handling.mdx +++ b/docs/building/by-layer/L3/error-handling.mdx @@ -170,7 +170,25 @@ The replay-locally argument has carve-outs sellers MUST honor: ## Standard Error Codes -Standard error codes are defined in [`error-code.json`](https://adcontextprotocol.org/schemas/v3/enums/error-code.json). Sellers MAY use codes not in this vocabulary for platform-specific errors. Agents MUST handle unknown codes by falling back to the `recovery` classification. +Standard error codes are defined in [`error-code.json`](https://adcontextprotocol.org/schemas/v3/enums/error-code.json). The vocabulary is **open**: `error.code` is wire-typed as `string`, the standard codes are documentary, and senders MAY emit codes outside the standard set. + +### Forward-compatible decoding (normative) + +**The error-code vocabulary is open.** `error.code` is typed as `string` in [`core/error.json`](https://adcontextprotocol.org/schemas/v3/core/error.json) — not as a closed enum — so a strict JSON Schema validator MUST accept any string value. The standard vocabulary in `error-code.json` is documentary; it constrains neither sender nor receiver at the wire level. + +**Receivers MUST decode unknown codes.** A receiver pinned to AdCP version X that decodes a response carrying an `error.code` introduced in version X+1 (or a platform-specific code outside the standard vocabulary) MUST: + +1. Treat the response as well-formed — MUST NOT reject the envelope, throw a deserialization exception, or downgrade to a generic protocol error. +2. Recover the recovery classification from `error.recovery` (top-level field on the error envelope) when present. `error.recovery` is the normative carrier; the `enumMetadata.recovery` in `error-code.json` is the documentary mirror. +3. When `error.recovery` is absent (legacy senders), apply a conservative default. `transient` is the safe default for unknown codes — retry-with-backoff cannot do worse than terminal classification, and the manifest's [`error_code_policy.default_unknown_recovery`](https://adcontextprotocol.org/schemas/v3/manifest.schema.json) documents this as the canonical fallback. The `transient` default is **bounded** by the retry rules in [§ Retry Logic](#retry-logic) — receivers MUST apply `maxRetries` and the jittered exponential-backoff schedule, and MUST NOT loop indefinitely on a `transient` default. A hostile or buggy sender cannot induce unbounded retries against a conformant client; the open-enum decoding rule does not exempt receivers from the retry budget. + +**Senders MAY emit codes outside the receiver's pinned vocabulary.** A sender emitting a 3.1-era code (e.g., `PROPOSAL_NOT_FOUND`) to a 3.0-pinned receiver does not violate the spec — the receiver is required by the rule above to handle it. When a sender knows the receiver's pinned version (via `adcp_version` envelope echo or capability discovery), senders SHOULD prefer a code from that version's vocabulary when an equivalent exists; senders MAY emit newer or platform-specific codes when no equivalent is available. + +**Senders MUST populate `error.recovery` on every error.** This is the normative carrier of recovery semantics across version skew — receivers cannot reliably classify a code they don't know, but they can always read `error.recovery`. Omitting it defeats the forward-compat rule. + +**Why this matters.** Forward-compatible decoding is the wire-level invariant that lets future maintenance lines (3.1.x, 4.0.x, …) ship new error codes additively without breaking older receivers. Without it, every new code is a wire change held to the next minor — the current drift-lint policy on 3.0.x. With it in 3.1+, new codes can register during a maintenance line's lifetime, which matters as adopters' real-world rejection paths surface new codes. + +**3.0.x policy unchanged.** 3.0.x receivers predate this normative rule, so 3.0.x stays wire-stable for the remainder of its support window — new codes still land at the next minor. This rule sets the receiver contract from 3.1 onward. **Not-found precedence.** When a referenced identifier does not resolve, sellers SHOULD return the resource-specific code when the resolved type is known from the request: `PRODUCT_NOT_FOUND` for `product_id`, `PACKAGE_NOT_FOUND` for `package_id`, `MEDIA_BUY_NOT_FOUND` for `media_buy_id`, `CREATIVE_NOT_FOUND` for `creative_id`, `SIGNAL_NOT_FOUND` for `signal_id`, `SESSION_NOT_FOUND` for SI `session_id`, `ACCOUNT_NOT_FOUND` for `account_id`, `PLAN_NOT_FOUND` for governance `plan_id`. Fall back to `REFERENCE_NOT_FOUND` for resource types without a dedicated code (e.g., property lists, content standards, rights grants, SI offerings, proposals, catalogs, event sources, collection lists, brands, individual properties). Typed parameters that lack a dedicated standard code MUST use `REFERENCE_NOT_FOUND` rather than minting a custom `*_NOT_FOUND` code — the vocabulary grows by upstream spec change, not by per-seller inflation. Clients SHOULD switch on `error.code` first; the resource-specific codes let clients dispatch without parsing `error.field`. @@ -476,6 +494,8 @@ The wire-level `recovery: "correctable"` on the sandbox-only path is the registe | `PRODUCT_NOT_FOUND` | correctable | Referenced product IDs are unknown or expired | Remove invalid IDs, or re-discover with `get_products` | | `PRODUCT_UNAVAILABLE` | correctable | Product is sold out or no longer available | Choose a different product | | `PROPOSAL_EXPIRED` | correctable | Referenced proposal has passed its `expires_at` | Run `get_products` to get a fresh proposal | +| `PROPOSAL_NOT_FOUND` | correctable | `proposal_id` is unknown to the seller (never finalized, wrong tenant, or evicted from cache) | Re-issue `get_products` with `buying_mode: "refine"` + `action: "finalize"` to obtain a current proposal_id | +| `MULTI_FINALIZE_UNSUPPORTED` | correctable | `refine[]` carried multiple `action: "finalize"` entries; seller cannot guarantee atomic multi-proposal commit | Sequence single-proposal finalize calls (one finalize per `get_products` call) | | `REQUOTE_REQUIRED` | correctable | Requested update falls outside the envelope (budget, dates, volume, targeting) the original quote was priced against; `pricing_option` remains locked | Call `get_products` with `buying_mode: "refine"` against the existing `proposal_id`, then resubmit against the new `proposal_id` | | `SIGNAL_NOT_FOUND` | correctable | Referenced signal does not exist in the catalog | Verify `signal_id` via `get_signals`, or confirm availability from this agent | | `AUDIENCE_TOO_SMALL` | correctable | Audience segment below minimum size | Broaden targeting or upload more audience members | @@ -530,6 +550,8 @@ function isRetryable(error) { ## Retry Logic +The rules in this section bound every `transient`-classified error a caller may retry, including the `transient` default applied to unknown error codes under [§ Forward-compatible decoding](#forward-compatible-decoding-normative). A receiver that decodes an unknown code and falls back to `transient` MUST apply `maxRetries` and the jittered exponential-backoff schedule below; the open-enum decoding rule does not exempt receivers from the retry budget. A hostile or buggy sender emitting `code=GO_FOREVER, recovery=transient` cannot induce unbounded retries against a conformant client. + ### Normative throttling behavior These rules apply when a caller receives a throttling-category error (`RATE_LIMITED`, or any error whose `recovery` is `transient` and whose `details` conform to the [`rate-limited`](https://adcontextprotocol.org/schemas/v3/error-details/rate-limited.json) detail shape): @@ -864,7 +886,7 @@ Leaks (don't): 1. **Check `recovery` first** — it's the most reliable signal for how to handle an error 2. **Implement retries** — use exponential backoff for transient errors 3. **Respect rate limits** — honor `retry_after` values -4. **Handle unknown codes gracefully** — fall back to the `recovery` classification +4. **Handle unknown codes gracefully** — fall back to `error.recovery`; default to `transient` when absent (see [Forward-compatible decoding](#forward-compatible-decoding-normative)) 5. **Log with context** — include `code`, `recovery`, and `field` for debugging 6. **Fallback strategies** — always have a backup (e.g., polling for webhooks) 7. **Don't retry terminal errors** — escalate to a human operator diff --git a/docs/building/operating/transport-errors.mdx b/docs/building/operating/transport-errors.mdx index 14cd28bfe4..dd2f0ea1e8 100644 --- a/docs/building/operating/transport-errors.mdx +++ b/docs/building/operating/transport-errors.mdx @@ -338,6 +338,8 @@ const CODE_RECOVERY = { PRODUCT_NOT_FOUND: 'correctable', PRODUCT_UNAVAILABLE: 'correctable', PROPOSAL_EXPIRED: 'correctable', + PROPOSAL_NOT_FOUND: 'correctable', + MULTI_FINALIZE_UNSUPPORTED: 'correctable', REQUOTE_REQUIRED: 'correctable', BUDGET_TOO_LOW: 'correctable', CREATIVE_REJECTED: 'correctable', diff --git a/docs/media-buy/media-buys/index.mdx b/docs/media-buy/media-buys/index.mdx index f56a05603f..4540bfaec1 100644 --- a/docs/media-buy/media-buys/index.mdx +++ b/docs/media-buy/media-buys/index.mdx @@ -262,8 +262,10 @@ pending_start ──────▶ rejected (terminal) — seller rejects duri Any non-terminal ──── update(canceled: true) ──▶ canceled (terminal) ``` -- **`pending_creatives`**: Approved but no creatives assigned +- **`pending_creatives`**: Approved but no creatives assigned — **buyer-side action required** (call `sync_creatives`). Not a publisher- or governance-side approval queue: the seller has already accepted the buy; only the buyer's creative submission is missing. - **`pending_start`**: Ready to serve, waiting for flight date + +The `pending_X` naming convention names the lifecycle phase that is next required, NOT a state of waiting on seller/operator approval — `pending_creatives` means "creatives are the next phase," `pending_start` means "the flight date start is the next phase." Both are post-seller-acceptance states. - **`active`**: Running and delivering impressions - **`paused`**: Temporarily stopped by buyer or seller - **`completed`**: Finished — flight ended, goal met, or budget exhausted diff --git a/docs/media-buy/product-discovery/refinement.mdx b/docs/media-buy/product-discovery/refinement.mdx index 504cc15f34..0198c969ed 100644 --- a/docs/media-buy/product-discovery/refinement.mdx +++ b/docs/media-buy/product-discovery/refinement.mdx @@ -197,6 +197,36 @@ The product entries define which products the seller should consider for the pro } ``` +## Finalize is exclusive within `refine[]` + +`action: "finalize"` is a commit, not a refinement — it transitions a draft proposal to committed with an expires_at hold window. When a buyer wants to finalize, the spec requires the `refine[]` array contain **only** finalize entries: + +- If any entry has `action: "finalize"`, **all** entries in the array MUST be proposal-scoped with `action: "finalize"`. Mixing finalize with `include` / `omit` entries, or with request- or product-scoped entries, MUST be rejected by the seller with `INVALID_REQUEST`. +- Buyers needing to refine *and* commit in close succession **sequence** the calls: first a refine call (no finalize), then a finalize call against the resulting `proposal_id`(s). The two intents are distinct decisions and the spec treats them as distinct calls. + +```yaml +# ✅ Refine only — no finalize present, mixed scopes allowed +refine: + - { scope: proposal, proposal_id: p1, ask: "shift more to ctv" } + - { scope: request, ask: "frequency cap 3/day across all products" } + +# ✅ Finalize only — multiple finalize entries against different proposals +refine: + - { scope: proposal, proposal_id: p1, action: finalize } + - { scope: proposal, proposal_id: p2, action: finalize } + +# ❌ Rejected — finalize mixed with non-finalize +refine: + - { scope: proposal, proposal_id: p1, action: finalize } + - { scope: proposal, proposal_id: p2, ask: "shift more to ctv" } +``` + +**Multi-finalize is atomic at the observation point.** When multiple finalize entries target different proposals in one call, the contract is: sellers MUST NOT return a success response unless every named proposal has both completed and been persisted as committed. Pre-commit validation runs before any side-effects (inventory pull, terms lock, governance attestation); if any proposal fails validation, the seller MUST reject the entire call without committing any. There is no `unfinalize` operation — atomicity runs on the pre-commit validation gate, not on post-commit reversal. Sellers that cannot guarantee atomic pre-commit validation MUST reject multi-finalize arrays with `MULTI_FINALIZE_UNSUPPORTED` (preferred — explicitly signals seller-side capability gap rather than client-side mistake) or `INVALID_REQUEST` (acceptable fallback for sellers on a pre-3.1 error catalog), and buyers SHOULD then sequence single-finalize calls. + +**Mid-commit failure (post-validation, pre-persist).** If a downstream system fails between commit one and commit two — e.g., the second ad server times out after the first has already locked inventory — the seller MUST return `INTERNAL_ERROR` with `refinement_applied[]` carrying per-position outcomes. The spec does NOT define a recovery path: buyers SHOULD treat the resulting state as undefined and re-read via `get_media_buys` / equivalent before retrying. Recovery from this case is operational, not protocol-defined. + +**Buyer intent caveat.** Buyers whose intent specifically requires atomic commit (e.g., budget-shared proposals where one finalizing without the other is incoherent) MUST be prepared to abandon the intent if the seller returns `MULTI_FINALIZE_UNSUPPORTED`. The fallback path — sequencing single-finalize calls — is a looser commit guarantee than the original atomic intent; there is no recovery for that loss of intent beyond accepting the looser guarantee or declining to commit at all. There is no capability flag for multi-finalize support — the failure response is the discovery surface, so buyers MUST NOT assume support without a successful first attempt. + ## Proposals in refine mode Sellers MAY return proposals alongside refined products, even when the buyer did not include proposal entries. For example, a buyer refining three products may receive those products back with updated pricing *and* a proposal suggesting how to combine them. @@ -236,6 +266,8 @@ Client implementations should validate refinement requests against the [request |------------|------|------------| | `PRODUCT_NOT_FOUND` | One or more referenced product IDs are unknown or expired | Remove invalid IDs and retry, or re-discover with a `brief` request | | `PROPOSAL_EXPIRED` | A referenced proposal ID has passed its `expires_at` | Re-discover with a new `brief` or `wholesale` request | +| `PROPOSAL_NOT_FOUND` | The referenced `proposal_id` is unknown to the seller (never finalized, wrong tenant, or evicted from cache) | Re-issue `get_products` in `refine` mode with `action: 'finalize'` to obtain a current proposal_id | +| `MULTI_FINALIZE_UNSUPPORTED` | `refine[]` carried multiple `action: 'finalize'` entries but the seller cannot guarantee atomic multi-proposal commit | Sequence single-proposal finalize calls (one finalize per `get_products` call) | | `INVALID_REQUEST` | `refine` provided in `brief` or `wholesale` mode, empty `refine` array, or missing required fields | Check `buying_mode` and required fields | ### Troubleshooting: "must NOT have additional properties" on `refine[].id` diff --git a/docs/media-buy/task-reference/get_products.mdx b/docs/media-buy/task-reference/get_products.mdx index e0b6756539..9d8c45baff 100644 --- a/docs/media-buy/task-reference/get_products.mdx +++ b/docs/media-buy/task-reference/get_products.mdx @@ -974,6 +974,8 @@ Key rules to know before sending: | `INVALID_REQUEST` | Brief too long or malformed filters | Check request parameters | | `PRODUCT_NOT_FOUND` | One or more referenced product IDs are unknown or expired | Remove invalid IDs and retry, or re-discover with a `brief` request | | `PROPOSAL_EXPIRED` | A referenced proposal ID has passed its `expires_at` timestamp | Re-discover with a new `brief` or `wholesale` request | +| `PROPOSAL_NOT_FOUND` | The referenced `proposal_id` is unknown to the seller (never finalized, wrong tenant, or evicted from cache) | Re-issue `get_products` in `refine` mode with `action: 'finalize'` to obtain a current proposal_id | +| `MULTI_FINALIZE_UNSUPPORTED` | `refine[]` carried multiple `action: 'finalize'` entries but the seller cannot guarantee atomic multi-proposal commit | Sequence single-proposal finalize calls — one finalize entry per `get_products` call | | `POLICY_VIOLATION` | Category blocked for advertiser | See policy response message for details | ### Authentication Comparison diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index b3a6a8d06c..bac0420a09 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -84,11 +84,11 @@ Core AdCP protocol information: | Field | Type | Description | |-------|------|-------------| | `major_versions` | integer[] | **Required.** AdCP major versions supported (e.g., `[3]`) | -| `idempotency` | object | **Required.** Idempotency semantics for mutating requests. See [idempotency](#idempotency). | +| `idempotency` | object | **Required.** Idempotency semantics. See [idempotency](#idempotency). | #### idempotency -Declares whether this seller honors `idempotency_key` replay protection on mutating requests. Mirrors the `request_signing.supported` pattern — a single positive declaration, decoupled from the window detail. Clients MUST NOT assume a default; a seller without this block is non-compliant and should be treated as unsafe for retry-sensitive operations. +Declares whether this seller honors `idempotency_key` replay protection. From 3.1 onward `idempotency_key` is required on every AdCP task request (read and mutating alike — staged enforcement for reads: SHOULD-reject in 3.1, MUST-reject in 3.2; see [security.mdx § Idempotency](/docs/building/by-layer/L1/security#idempotency)). Mirrors the `request_signing.supported` pattern — a single positive declaration, decoupled from the window detail. Clients MUST NOT assume a default; a seller without this block is non-compliant and should be treated as unsafe for retry-sensitive operations across all call modes. | Field | Type | Description | |-------|------|-------------| diff --git a/docs/reference/release-notes.mdx b/docs/reference/release-notes.mdx index 46b4b7a045..a104260a49 100644 --- a/docs/reference/release-notes.mdx +++ b/docs/reference/release-notes.mdx @@ -67,6 +67,48 @@ See [`brand.json` § Distributed publishing](/docs/brand-protocol/brand-json) fo Additive over 3.0. Existing sellers continue to validate; buyers ignoring the new fields keep working. Buyers SHOULD upgrade reach-summation logic to gate on `reach_window` semantics. +### Wire conformance — idempotency & envelope tolerance (#4399 / #4399b, #2911, #4227, #4371, #4418, #4107, #4196, #4043) + +A bundle of normative clarifications that codify deployed behavior and close ambiguities that surfaced from real adopter integrations. None require code changes for SDK-using integrators; hand-rolled MCP clients should review the universal-idempotency staged enforcement curve before the 3.2 cut. + +**`idempotency_key` is required on every AdCP task request — read and mutating alike (#4399b).** The 3.0 contract framed it as mutating-only, but `get_products` is polymorphic (`brief`/`wholesale` may return `Submitted`; `refine`+`finalize` is a commit) and buyers can't classify at call time. Enforcement is **staged**: + +- **3.1.0** — sellers MUST accept reads that carry `idempotency_key`; SHOULD reject reads that omit it (MAY accept the omission during the 3.1.x maintenance window). +- **3.2.0** — sellers MUST reject reads that omit it. + +The cache holds read responses too from 3.1 onward — sellers MUST encrypt the cache tier at rest with the same controls applied to the underlying resource store. Operators with read-heavy buyer mixes SHOULD raise the rule-8 insert ceiling (sized originally against write-heavy traffic). See [security.mdx § Idempotency](/docs/building/by-layer/L1/security#idempotency) for the full contract. + +**MCP envelope serialization is now normative — flat on the wire (#2911).** `core/protocol-envelope.json` drops `required: [status, payload]`; envelope fields and body fields are siblings at the root of the MCP tool response (no nested `payload:` key). Matches shipping SDK behavior. `context` joins the envelope as a first-class field — caller-supplied opaque echo (`/schemas/core/context.json`) distinct from `context_id` (server-managed session). A2A and REST serializations are normative in the envelope's `notes` array. + +**MCP tool wrappers MUST tolerate envelope fields (#4399).** Server-side counterpart to `additionalProperties: true` on request schemas. FastMCP/Pydantic strict signatures, Zod `.strict()`, and OpenAPI codegen with `additionalProperties: false` injected into input models are all non-conformant. See [mcp-guide.mdx § Server-side tool wrappers](/docs/building/by-layer/L0/mcp-guide#server-side-tool-wrappers-must-tolerate-envelope-fields). + +**`error.code` decoding is forward-compatible by contract (#4227).** Receivers MUST treat unknown `error.code` values as valid, read `error.recovery` (the normative wire carrier from 3.1 onward) for the recovery class, default to `transient` when absent (bounded by §Retry Logic). Senders SHOULD populate `error.recovery` on every error from 3.1 onward — receivers across version skew can't rely on `enumMetadata.recovery` for codes they don't know. Sellers upgrading 3.0.x → 3.1 SHOULD audit error-emit paths before bumping the advertised `adcp_version`. See [error-handling.mdx § Forward-compatible decoding](/docs/building/by-layer/L3/error-handling#forward-compatible-decoding-normative). + +**Idempotency replay returns historical snapshots, not current state (#4371).** Rule 2's immutable-cache invariant extended explicitly to synchronous-success responses: state-tracking fields in cached payloads (`status`, `packages`, `affected_packages`, etc.) MUST NOT refresh on replay. Buyers reading state from a `replayed: true` response MUST re-read via the resource's read endpoint (`get_media_buys`, `list_accounts`, etc.) before acting on it. + +**`refine[]` finalize-exclusivity (#4107).** If any `refine[]` entry on `get_products` has `action: 'finalize'`, ALL entries MUST be proposal-scoped finalize entries. Mixing finalize with non-finalize entries is rejected with `INVALID_REQUEST`. Multi-finalize is atomic across proposal_ids; sellers that can't guarantee atomicity reject with `INVALID_REQUEST` (or `MULTI_FINALIZE_UNSUPPORTED` for a more specific signal). See [refinement guide § Finalize is exclusive](/docs/media-buy/product-discovery/refinement#finalize-is-exclusive-within-refine). + +**`notices` advisory channel on runner-output-contract (#4418).** New `step_result.notices` and `run_summary.notices` arrays — three severities (`info` / `deprecation` / `future_required`) with three canonical first-day codes (`signed_requests_specialism_deprecated`, `request_signing_required_in_4_0`, `legacy_hmac_fallback_removed_in_4_0`). Conformance dashboards will start surfacing 4.0 deprecation advisories as machine-readable notice codes rather than prose `skip.detail` strings. + +**Error catalog additions:** `PROPOSAL_NOT_FOUND` (#4043), `MULTI_FINALIZE_UNSUPPORTED` (#4107). Both forward-compat-safe under the #4227 decoding rule — 3.0 receivers see them, read `error.recovery: correctable`, and route accordingly without needing a vocabulary bump. + +**Description sharpenings:** `media-buy-status.pending_creatives` description leads with "Buyer-side action required" and explicitly contrasts with publisher/governance approval flows (#4196 — document, not rename; consistent with the `pending_X` convention). + +### Adopter action — wire-conformance bundle + +| If you are… | What you need to do | +|---|---| +| A buyer using `@adcp/client` or `adcp-py` | Nothing. Both SDKs already send `idempotency_key` uniformly on every call. | +| A buyer with hand-rolled MCP clients (curl, thin clients, raw OpenAPI codegen) | Add `idempotency_key` to read tools (`get_products`, `list_creative_formats`, `list_accounts`, etc.) before the 3.2 cut. 3.1.0 sellers MAY accept omission during the grace window; 3.2.0 sellers MUST reject. | +| A seller on FastMCP/Pydantic, Zod `.strict()`, or OpenAPI codegen | Audit input models — they MUST accept envelope fields (`idempotency_key`, `context_id`, `context`, `governance_context`, `push_notification_config`) on every tool. Use `extra='allow'` (Pydantic), drop `.strict()` (Zod), or fix codegen config (OpenAPI). | +| A seller emitting `create_media_buy` synchronous-success responses with embedded `status` | Nothing changes. Existing byte-stable replay already conforms; the rule was silent on this case before, now it's explicit. | +| A buyer agent reading `status` from a mutation response | Add a `replayed: true` guard: if set, call the resource's read endpoint (`get_media_buys`, `list_accounts`, etc.) before any state-dependent action. Without the guard, you'll hit `NOT_CANCELLABLE` and similar state-machine bugs against replayed responses. | +| A seller emitting unsigned webhooks or claiming the deprecated `signed-requests` specialism | Read the new `notices` field on runner-output-contract — your conformance dashboards will start surfacing `request_signing_required_in_4_0` / `legacy_hmac_fallback_removed_in_4_0` / `signed_requests_specialism_deprecated`. Behavior is unchanged in 3.1; the advisories are forward-readiness for 4.0. | +| A seller currently emitting unknown error codes (platform-specific or pre-3.1 additions) | Add `error.recovery` to every error envelope before bumping advertised `adcp_version` to 3.1. Receivers default to `transient` when absent, which is safe but suboptimal — populating the field correctly is the spec-compliant path. | +| An adopter with strict-validator test fixtures or codegen against `core/protocol-envelope.json` | Refresh fixtures and decoders. The schema drops `required: [status, payload]` (#2911) — strict validators that asserted "envelope MUST reject responses missing `payload`" will start accepting envelopes they used to reject. SDK consumers using OpenAPI / quicktype / Pydantic codegen against the envelope will see `status` and `payload` flip from required to optional in generated types; audit downstream decoders that depended on those being present at the schema level. A2A consumers carry `status` via `task.status.state`; MCP/REST consumers reading the envelope as a pure JSON Schema decoder are the affected set. The new normative serialization rules in the envelope's `notes` describe what wire shape to expect per transport. | + +Additive over 3.0 — the entire bundle preserves wire compatibility for 3.0-conformant agents; the changes formalize behavior shipping SDKs already implement. + ### Optimization goals — `kind: "vendor_metric"` (#4668, closes #4644) End-to-end vendor binding for optimization goals against vendor-attested measurement — attention (DV, IAS, Adelaide, TVision, Lumen), panel-based brand lift (Kantar, Upwave, Cint), emissions (Scope3, Good-Loop), retail-media partner metrics. Closes the goal-side gap that the #4618 release notes flagged: today's vendor-agnostic `attention_seconds` / `attention_score` enum values are meaningless without a vendor binding, since each measurement vendor defines them differently. diff --git a/scripts/error-code-drift-dispositions.json b/scripts/error-code-drift-dispositions.json index 6ecfba38e0..191569a6ff 100644 --- a/scripts/error-code-drift-dispositions.json +++ b/scripts/error-code-drift-dispositions.json @@ -76,6 +76,16 @@ "target_version": "3.1", "note": "From sync_accounts billing coverage (#3831). Wire change \u2014 held for 3.1." }, + "MULTI_FINALIZE_UNSUPPORTED": { + "disposition": "held-for-next-minor", + "target_version": "3.1", + "note": "Seller-side capability gap signal for #4107 multi-finalize atomicity. Returned when refine[] carries multiple action: finalize entries against different proposal_ids and the seller's downstream stack cannot guarantee atomic commit (e.g., proposals route to two different ad servers with no 2PC). More specific than INVALID_REQUEST so buyers can distinguish 'doesn't support multi-finalize' from 'request is malformed'. Buyer-fixable: sequence single-proposal finalize calls. Wire change \u2014 held for 3.1." + }, + "PROPOSAL_NOT_FOUND": { + "disposition": "held-for-next-minor", + "target_version": "3.1", + "note": "Proposal-lifecycle counterpart to PROPOSAL_EXPIRED / PROPOSAL_NOT_COMMITTED (#4043). Returned when a referenced proposal_id is unknown to the seller (never finalized, wrong tenant, or evicted from cache). Buyer-fixable: re-issue get_products in refine+finalize to obtain a current proposal_id. Wire change \u2014 held for 3.1." + }, "PROVENANCE_CLAIM_CONTRADICTED": { "disposition": "held-for-next-minor", "target_version": "3.1", diff --git a/static/compliance/source/universal/runner-output-contract.yaml b/static/compliance/source/universal/runner-output-contract.yaml index 52d1098e8d..ef31f9d000 100644 --- a/static/compliance/source/universal/runner-output-contract.yaml +++ b/static/compliance/source/universal/runner-output-contract.yaml @@ -26,7 +26,7 @@ # --- Schema definition --- id: runner_output_contract -version: "2.1.0" +version: "2.2.0" title: "Runner output contract" summary: "Required failure-detail shape that AdCP storyboard runners MUST emit so implementors can self-diagnose validation failures." @@ -111,6 +111,10 @@ step_result: - error # transport-level error (no validations attempted) - request # exact request the runner sent (see request block) - response # exact response observed (see response block) + - notices # array of notice objects attached to this step's + # result — informational signals that MUST NOT + # change `passed`. See the notice block below for + # the field shape and the canonical first-day codes. validation_result: description: | @@ -789,6 +793,132 @@ skip_result: DETAILED_SKIP_TO_CANONICAL in the conforming implementation for a concrete mapping. +notice: + description: | + Advisory signal attached to a storyboard step result or run summary. + Notices are informational — they MUST NOT contribute to steps_failed, + validations_failed, or any other required-failure counter, and they + MUST NOT change `step_result.passed` or run-level pass/fail counters. + + Notices fill the gap between three existing signals: + - Validation failures (passed: false) — the agent did something wrong. + - Skip reasons — the runner could not apply the storyboard. + - Advisory-severity validations — the storyboard author marked a check + as non-blocking during a runner adoption window. + + A notice is none of those: it is a forward-looking or migration advisory + about a passing observation. Examples: an agent still advertising a + deprecated specialism that the spec recommends dropping; an agent that + passes today but advertises a capability shape that will be required at + a named future spec version; an adopter using a legacy fallback that is + scheduled for removal at a named future version. + + Spec storyboards that motivate canonical notices SHOULD reference the + notice `code` (not the prose). The reference adopter currently + motivating the contract is the `signed_requests_specialism_deprecated` + notice in `universal/signed-requests.yaml` — the runner SHOULD emit + this notice when an agent claims the deprecated `signed-requests` + specialism alongside `request_signing.supported: true`, advising + the agent to drop the now-redundant specialism claim. + + Forward compatibility: receivers MUST treat an unknown `code` or + `severity` value as well-formed and surface the notice verbatim. New + codes and severities ship additively; rejecting on unknown values + defeats the contract. + + required_fields: + - severity # "info" | "deprecation" | "future_required" + # info — passing behavior is fine + # today; advisory context only + # (e.g., "this agent is using + # an alternate but supported + # pathway"). + # deprecation — agent-side claim or behavior + # is allowed today but the + # spec recommends migration + # (e.g., legacy specialism + # enum values still accepted + # for back-compat). + # future_required — capability that is optional + # today will be required at a + # named future spec version; + # agents that do not advertise + # it will fail compliance + # after the cut. `effective_version` + # MUST be populated. + - code # stable machine-readable identifier + # (SCREAMING_SNAKE-or-lowercase_snake; the + # canonical first-day codes below use + # lowercase_snake). Runners MUST NOT change + # the code for the same advisory across + # versions — consumers aggregate by code. + # Unknown codes from a forward-compat runner + # MUST be surfaced verbatim by receivers, not + # rejected. + - message # human-readable description. Runners MUST + # fence this string when rendering to a + # shared LLM-fed surface per the same rules + # as skip_result.detail — the storyboard + # author controls the substring. + optional_fields: + - effective_version # When severity is "future_required", the + # AdCP version that flips the requirement + # (e.g., "4.0"). REQUIRED for future_required + # severity, MAY be present on "deprecation" + # to name the removal version, MUST NOT be + # set on "info". + - requirement # When the notice is tied to a structured + # `requires:` name in storyboard-schema.yaml, + # repeat the requirement value here so + # dashboards can correlate notices with the + # gate that motivated them. + - capability_path # Dotted path into the agent's + # `get_adcp_capabilities` response that + # motivated the notice + # (e.g., "request_signing.supported", + # "webhook_signing.legacy_hmac_fallback"). + # Lets consumers link the notice to the + # exact capability flag without parsing the + # message string. + - reference_url # optional spec or issue URL with more context + # (e.g., the upstream issue tracking the + # deprecation, or the relevant section in + # /docs/building/by-layer/L3/error-handling.mdx). + canonical_codes: + description: | + First-day published codes runners SHOULD emit when the underlying + condition holds. Additional codes are registered by adding entries + here in subsequent spec revisions; runners MAY emit codes outside + this list (consumers must accept unknown codes per the forward-compat + rule above), but SHOULD prefer canonical codes when one fits. + signed_requests_specialism_deprecated: + severity: deprecation + spec_source: universal/signed-requests.yaml + capability_path: specialisms + message_template: | + Agent advertises the deprecated `signed-requests` specialism + enum value. Drop it and rely solely on + `request_signing.supported: true`. The enum value is removed + in AdCP 4.0 (see adcontextprotocol/adcp#3078). + request_signing_required_in_4_0: + severity: future_required + effective_version: "4.0" + spec_source: protocol/get-adcp-capabilities-response.json#request_signing + capability_path: request_signing.supported + message_template: | + `request_signing.supported: true` is optional in 3.x but required + for spend-committing operations in AdCP 4.0. Agents that do not + advertise support will fail compliance on the 4.0 cut. + legacy_hmac_fallback_removed_in_4_0: + severity: deprecation + effective_version: "4.0" + spec_source: protocol/get-adcp-capabilities-response.json#webhook_signing.legacy_hmac_fallback + capability_path: webhook_signing.legacy_hmac_fallback + message_template: | + Agent advertises `webhook_signing.legacy_hmac_fallback: true`. + The HMAC fallback path is deprecated and removed in AdCP 4.0; + receivers SHOULD migrate to RFC 9421 signatures only. + cascade_rules: description: | Rules governing when a runner MUST propagate `prerequisite_failed` to @@ -919,6 +1049,19 @@ run_summary: # declared check type). Surfaces "runner is # older than the storyboard" as a distinct # signal from clean passes. + - notices # array of notice objects scoped to the run + # rather than a specific step (e.g., a + # `request_signing_required_in_4_0` notice + # emitted once per run when the agent does + # not advertise `request_signing.supported`, + # regardless of which storyboard the + # observation surfaced on). Runners MAY + # emit the same code on step_result.notices + # AND run_summary.notices when an advisory + # is both step-specific and run-wide; + # consumers dedupe by `code`. See the + # notice block above for shape and + # canonical_codes. - validations_advisory_failed # count of validation_result entries with # passed: false and severity: advisory. # These do NOT contribute to steps_failed diff --git a/static/compliance/source/universal/signed-requests.yaml b/static/compliance/source/universal/signed-requests.yaml index e587781f78..b526e74c21 100644 --- a/static/compliance/source/universal/signed-requests.yaml +++ b/static/compliance/source/universal/signed-requests.yaml @@ -31,9 +31,11 @@ narrative: | enum value remains in the schema for back-compat (see [#3075](https://github.com/adcontextprotocol/adcp/issues/3075)). Agents that still advertise `specialisms: ["signed-requests"]` are graded via this universal storyboard; - the runner SHOULD emit an informational notice (not a failure) advising the agent to - drop the now-redundant specialism claim and rely solely on `request_signing.supported: true`. - 4.0 removes the enum value entirely — see + the runner SHOULD emit the `signed_requests_specialism_deprecated` notice (severity + `deprecation`, see `runner-output-contract.yaml` > `notice` > `canonical_codes`) advising + the agent to drop the now-redundant specialism claim and rely solely on + `request_signing.supported: true`. The notice MUST NOT change `step_result.passed` — + it is an advisory, not a failure. 4.0 removes the enum value entirely — see [#3078](https://github.com/adcontextprotocol/adcp/issues/3078). **Composition with other storyboards.** This storyboard runs independently of any diff --git a/static/schemas/source/collection/create-collection-list-response.json b/static/schemas/source/collection/create-collection-list-response.json index 8d3913d11a..8e9796c672 100644 --- a/static/schemas/source/collection/create-collection-list-response.json +++ b/static/schemas/source/collection/create-collection-list-response.json @@ -20,7 +20,7 @@ }, "replayed": { "type": "boolean", - "description": "Set to true when this response is a cached replay returned for an idempotency_key that was already processed. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, and any downstream system that assumes exactly-once event semantics. Only present on responses to mutating requests that carry idempotency_key.", + "description": "Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. From 3.1 onward, `replayed` MAY appear on responses to any request that resolved via the idempotency cache, including read tools — universal `idempotency_key` (see security.mdx §Idempotency) means the cache holds read responses too.", "default": false }, "context": { diff --git a/static/schemas/source/collection/delete-collection-list-response.json b/static/schemas/source/collection/delete-collection-list-response.json index 7736dab4d9..d37012df38 100644 --- a/static/schemas/source/collection/delete-collection-list-response.json +++ b/static/schemas/source/collection/delete-collection-list-response.json @@ -21,7 +21,7 @@ }, "replayed": { "type": "boolean", - "description": "Set to true when this response is a cached replay returned for an idempotency_key that was already processed. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, and any downstream system that assumes exactly-once event semantics. Only present on responses to mutating requests that carry idempotency_key.", + "description": "Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. From 3.1 onward, `replayed` MAY appear on responses to any request that resolved via the idempotency cache, including read tools — universal `idempotency_key` (see security.mdx §Idempotency) means the cache holds read responses too.", "default": false }, "context": { diff --git a/static/schemas/source/collection/update-collection-list-response.json b/static/schemas/source/collection/update-collection-list-response.json index e8447e082d..ebecbcec11 100644 --- a/static/schemas/source/collection/update-collection-list-response.json +++ b/static/schemas/source/collection/update-collection-list-response.json @@ -16,7 +16,7 @@ }, "replayed": { "type": "boolean", - "description": "Set to true when this response is a cached replay returned for an idempotency_key that was already processed. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, and any downstream system that assumes exactly-once event semantics. Only present on responses to mutating requests that carry idempotency_key.", + "description": "Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. From 3.1 onward, `replayed` MAY appear on responses to any request that resolved via the idempotency cache, including read tools — universal `idempotency_key` (see security.mdx §Idempotency) means the cache holds read responses too.", "default": false }, "context": { diff --git a/static/schemas/source/core/error.json b/static/schemas/source/core/error.json index 00b49c88ea..a4c920d661 100644 --- a/static/schemas/source/core/error.json +++ b/static/schemas/source/core/error.json @@ -9,7 +9,7 @@ "type": "string", "minLength": 1, "maxLength": 64, - "description": "Error code for programmatic handling. Standard codes are defined in error-code.json and enable autonomous agent recovery. Sellers MAY use codes not in the standard vocabulary for platform-specific errors; agents MUST handle unknown codes gracefully by falling back to the recovery classification." + "description": "Error code for programmatic handling. The error-code vocabulary is open: `error.code` is wire-typed `string` (not a closed enum), the standard codes published in `enums/error-code.json` are documentary, and senders MAY emit codes outside that set (platform-specific codes, or codes introduced in a later AdCP version). Receivers MUST decode unknown codes — treat the response as well-formed, read `error.recovery` for the recovery classification, and fall back to `transient` when `recovery` is absent. See `error-handling.mdx#forward-compatible-decoding-normative` for the full forward-compat contract — this rule is what lets future maintenance lines ship new codes additively." }, "message": { "type": "string", @@ -87,7 +87,7 @@ "recovery": { "type": "string", "enum": ["transient", "correctable", "terminal"], - "description": "Agent recovery classification. transient: retry after delay (rate limit, service unavailable, timeout). correctable: fix the request and resend (invalid field, budget too low, creative rejected). terminal: requires human action (account suspended, payment required, account not found)." + "description": "Agent recovery classification. transient: retry after delay (rate limit, service unavailable, timeout). correctable: fix the request and resend (invalid field, budget too low, creative rejected). terminal: requires human action (account suspended, payment required, account not found). Senders SHOULD populate `recovery` on every error from 3.1 onward — it is the normative carrier of recovery semantics across version skew. A receiver that does not recognize `error.code` (a newer code, or a platform-specific code) MUST still be able to classify the error from `recovery`. The `enumMetadata.recovery` block in `enums/error-code.json` is the documentary mirror for known codes; `error.recovery` on the wire is authoritative." }, "source": { "type": "string", diff --git a/static/schemas/source/core/protocol-envelope.json b/static/schemas/source/core/protocol-envelope.json index 2f4d784d9f..8b9e107e6b 100644 --- a/static/schemas/source/core/protocol-envelope.json +++ b/static/schemas/source/core/protocol-envelope.json @@ -2,12 +2,16 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/protocol-envelope.json", "title": "Protocol Envelope", - "description": "Standard envelope structure for AdCP task responses. This envelope is added by the protocol layer (MCP, A2A, REST) and wraps the task-specific response payload. Task response schemas should NOT include these fields - they are protocol-level concerns.", + "description": "Canonical envelope field-set for AdCP task responses, normalized across transports. Defines the protocol-layer fields (status, context_id, context, task_id, timestamp, replayed, adcp_error, push_notification_config, governance_context) and the conceptual `payload` grouping for task-specific response data. The serialization rules — whether envelope fields appear as siblings of payload fields, as a nested `payload` object, or via transport-native containers — are transport-specific and normative per transport (see Transport serialization below).", "type": "object", "properties": { "context_id": { "type": "string", - "description": "Session/conversation identifier for tracking related operations across multiple task invocations. Managed by the protocol layer to maintain conversational context." + "description": "Session/conversation identifier for tracking related operations across multiple task invocations. Managed by the protocol layer to maintain conversational context. Distinct from `context` (per-request opaque echo, see below)." + }, + "context": { + "$ref": "/schemas/core/context.json", + "description": "Per-request opaque caller-supplied correlation object echoed unchanged in the response. Used for buyer-side tracking (UI session IDs, trace IDs, custom metadata) that the agent MUST preserve byte-for-byte without parsing. Distinct from `context_id` (server-managed session identifier) — `context` is caller-owned echo, `context_id` is server-owned session scope. Both MAY appear on the same response.\n\n**Relationship to per-task body-level `context` declarations.** Many task request/response schemas (147 as of 3.1) already declare a body-level `context` field that `$ref`s `/schemas/core/context.json` at the body root. Under the flat-on-the-wire MCP serialization (see `notes` below), envelope-level `context` and body-level `context` occupy the same key on the response root — they are NOT separate fields, they MUST share the same value, and they MUST both `$ref` `core/context.json`. The envelope declaration is **authoritative** for the schema definition; per-task body declarations are mirrors retained for tooling reasons (SDK codegen completeness, per-task validation against the response schema in isolation). Future versions MAY drop body-level `context` declarations from per-task schemas; conformance does not require either declaration to be present, only that the wire value `$ref`s `core/context.json`." }, "task_id": { "type": "string", @@ -29,7 +33,7 @@ }, "replayed": { "type": "boolean", - "description": "Set to true when this response is a cached replay returned for an idempotency_key that was already processed. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, and any downstream system that assumes exactly-once event semantics. Only present on responses to mutating requests that carry idempotency_key.", + "description": "Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. From 3.1 onward, `replayed` MAY appear on responses to any request that resolved via the idempotency cache, including read tools — universal `idempotency_key` (see security.mdx §Idempotency) means the cache holds read responses too.", "default": false }, "adcp_error": { @@ -49,14 +53,10 @@ }, "payload": { "type": "object", - "description": "The actual task-specific response data. This is the content defined in individual task response schemas (e.g., get-products-response.json, create-media-buy-response.json). Contains only domain-specific data without protocol-level fields.", + "description": "Conceptual grouping for the task-specific response data defined by individual task response schemas (e.g., get-products-response.json, create-media-buy-response.json). `payload` is a documentary construct — it is NOT a required wire field, and its on-the-wire shape depends on transport (see Transport serialization below). Task response schemas declare body fields without wrapping them in a `payload` object; the wire representation places those body fields per transport convention. On MCP the body fields appear as siblings of envelope fields at the root of the tool response; on A2A they appear inside `task.artifacts[0].parts[].DataPart`; on REST they appear at the root of the JSON body.", "additionalProperties": true } }, - "required": [ - "status", - "payload" - ], "additionalProperties": true, "not": { "anyOf": [ @@ -169,12 +169,13 @@ } ], "notes": [ - "Task response schemas (e.g., get-products-response.json) define ONLY the payload structure", - "Protocol implementations (MCP, A2A, REST) wrap the payload with this envelope", - "Different protocols may use different serialization formats but maintain the same semantic structure", - "MCP may represent this via tool response content fields and metadata", - "A2A may represent this via assistant messages with structured data", - "REST may use HTTP headers for status/task metadata and JSON body for payload", - "The envelope ensures consistent behavior across all protocol implementations" + "Task response schemas (e.g., get-products-response.json) define ONLY the body fields; protocol-layer fields live on this envelope.", + "Transport serialization (normative):", + " - MCP: envelope fields and task-body fields are siblings at the root of the tool response. The `payload` object is NOT serialized as a nested key — its body fields are flattened to the root alongside `status`, `context_id`, `context`, etc. This matches MCP's native `structuredContent` convention and is what shipping SDKs (@adcp/client) emit. Conformant MCP receivers parse from the flat root; receivers that expect a nested `payload` key MUST migrate.", + " - A2A (0.3.0+): envelope fields map to A2A's native task metadata (`task.status.state` carries `status`, `task.contextId` carries `context_id`, `task.id` carries `task_id`). Task-body fields are canonically carried in `task.artifacts[0].parts[].DataPart` on final states; `task.status.message.parts[].DataPart` is the fallback container used only for interim states (working, input-required) where no final artifact has been emitted yet. Receivers MUST prefer artifacts when present. See `a2a-response-extraction.mdx` for the full canonical/fallback algorithm.", + " - REST: envelope fields MAY ride on HTTP headers (e.g., `X-AdCP-Status`, `X-AdCP-Context-Id`) or as JSON body siblings; body fields appear at the JSON body root. Implementers choosing the header path SHOULD also mirror to body siblings for non-streaming callers.", + "Across all three: envelope and body fields are conceptually a single response object. A task response schema MAY declare body fields with the same name as envelope fields (e.g., `errors[]` body-level for per-record validation results vs envelope-level for fatal task failure) and the two MUST be treated as distinct fields by name within their respective namespaces — see `error-handling.mdx#envelope-vs-payload-errors-the-two-layer-model`.", + "`status` and `payload` are intentionally NOT required on this schema. `status` may be carried via transport-native state on A2A (`task.status.state`); `payload` is a documentary grouping construct, never a required wire field. See `mcp-guide.mdx` and `a2a-guide.mdx` for the wire-level patterns receivers MUST implement.", + "Receivers MUST handle absence of an envelope field (e.g., `replayed` omitted) as the field's documented default — see each field's `default` clause." ] } diff --git a/static/schemas/source/enums/error-code.json b/static/schemas/source/enums/error-code.json index 48688decad..7973479e66 100644 --- a/static/schemas/source/enums/error-code.json +++ b/static/schemas/source/enums/error-code.json @@ -50,6 +50,8 @@ "VALIDATION_ERROR", "PRODUCT_EXPIRED", "PROPOSAL_NOT_COMMITTED", + "PROPOSAL_NOT_FOUND", + "MULTI_FINALIZE_UNSUPPORTED", "IO_REQUIRED", "TERMS_REJECTED", "REQUOTE_REQUIRED", @@ -129,6 +131,8 @@ "VALIDATION_ERROR": "Request contains invalid field values or violates business rules beyond schema validation. Recovery: correctable (review error details and fix field values).", "PRODUCT_EXPIRED": "One or more referenced products have passed their expires_at timestamp and are no longer available for purchase. Recovery: correctable (re-discover with get_products to find current inventory).", "PROPOSAL_NOT_COMMITTED": "The referenced proposal has proposal_status 'draft' and cannot be used to create a media buy. Recovery: correctable (finalize the proposal first using get_products with buying_mode 'refine' and action 'finalize').", + "PROPOSAL_NOT_FOUND": "The referenced proposal_id is not recognized by the seller — never finalized, belongs to a different tenant, or evicted from the seller's session cache before consumption. Distinct from `PROPOSAL_EXPIRED` (a known proposal whose `expires_at` window has passed) and `PROPOSAL_NOT_COMMITTED` (a known proposal still in `draft`). Recovery: correctable (re-issue `get_products` with `buying_mode: 'refine'` + `action: 'finalize'` to obtain a current proposal_id, then retry create_media_buy).", + "MULTI_FINALIZE_UNSUPPORTED": "Returned by sellers that cannot guarantee atomic commit across multiple proposals in a single `get_products` call (multiple `action: 'finalize'` entries in `refine[]` targeting different `proposal_id` values). The buyer's intent — atomic multi-proposal finalize — is structurally well-formed and per spec atomic, but this seller's downstream stack cannot satisfy the atomicity guarantee (e.g., the proposals route to two different ad servers with no 2PC). More specific than `INVALID_REQUEST` so buyers can distinguish 'this seller doesn't support multi-finalize' from 'the request itself is malformed'. Recovery: correctable (sequence the finalize calls one-`proposal_id`-per-call; there is no capability flag for multi-finalize support, so a successful first-attempt is the only positive discovery signal). See [refinement guide § Finalize is exclusive](/docs/media-buy/product-discovery/refinement#finalize-is-exclusive-within-refine).", "IO_REQUIRED": "The committed proposal requires a signed insertion order but no io_acceptance was provided. Recovery: correctable (review the proposal's insertion_order, accept terms, and include io_acceptance on create_media_buy).", "TERMS_REJECTED": "Buyer-proposed measurement_terms were rejected by the seller. The error details SHOULD identify which specific term was rejected and the seller's acceptable range or supported vendors. Recovery: correctable (adjust the proposed terms and retry, or omit measurement_terms to accept the product's defaults).", "REQUOTE_REQUIRED": "An update_media_buy request changes the parameter envelope (budget, flight dates, volume, targeting) the original quote was priced against. The pricing_option remains locked; the seller is declining the requested shape at that price. Distinct from TERMS_REJECTED (measurement) and POLICY_VIOLATION (content). Sellers SHOULD populate error.details.envelope_field with the field path(s) that breached the envelope (e.g., 'packages[0].budget', 'end_time') so the buyer's agent can autonomously re-discover. Recovery: correctable (re-negotiate via get_products in 'refine' mode against the existing proposal_id to obtain a fresh quote reflecting the new parameters, then resubmit the update against the new proposal_id).", @@ -344,6 +348,14 @@ "recovery": "correctable", "suggestion": "finalize the proposal first using get_products with buying_mode 'refine' and action 'finalize'" }, + "PROPOSAL_NOT_FOUND": { + "recovery": "correctable", + "suggestion": "re-issue get_products with buying_mode 'refine' and action 'finalize' to obtain a current proposal_id, then retry" + }, + "MULTI_FINALIZE_UNSUPPORTED": { + "recovery": "correctable", + "suggestion": "sequence single-proposal finalize calls — one finalize entry per get_products call" + }, "IO_REQUIRED": { "recovery": "correctable", "suggestion": "review the proposal's insertion_order, accept terms, and include io_acceptance on create_media_buy" diff --git a/static/schemas/source/enums/media-buy-status.json b/static/schemas/source/enums/media-buy-status.json index fb8d38ad66..f014b17105 100644 --- a/static/schemas/source/enums/media-buy-status.json +++ b/static/schemas/source/enums/media-buy-status.json @@ -14,7 +14,7 @@ "canceled" ], "enumDescriptions": { - "pending_creatives": "Media buy is approved but has no creatives assigned. The buyer must attach creatives via sync_creatives before the buy can serve.", + "pending_creatives": "**Buyer-side action required.** The media buy is approved by the seller and has no creatives assigned — the buyer must attach creatives via `sync_creatives` before the buy can serve. Not to be confused with a publisher-side or governance-side approval queue: the seller has already accepted the buy; only the buyer's creative submission is missing. Naming convention: `pending_X` names the lifecycle phase that is next required (here, `creatives`), not a state of waiting on seller/operator approval — consistent with `pending_start` (waiting for the flight date to begin).", "pending_start": "Media buy is ready to serve and waiting for its flight date to begin.", "active": "Media buy is currently running", "paused": "Media buy is temporarily paused", diff --git a/static/schemas/source/governance/report-plan-outcome-response.json b/static/schemas/source/governance/report-plan-outcome-response.json index 4a68051846..7f7e463c3f 100644 --- a/static/schemas/source/governance/report-plan-outcome-response.json +++ b/static/schemas/source/governance/report-plan-outcome-response.json @@ -76,7 +76,7 @@ }, "replayed": { "type": "boolean", - "description": "Set to true when this response is a cached replay returned for an idempotency_key that was already processed. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, and any downstream system that assumes exactly-once event semantics. Only present on responses to mutating requests that carry idempotency_key.", + "description": "Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. From 3.1 onward, `replayed` MAY appear on responses to any request that resolved via the idempotency cache, including read tools — universal `idempotency_key` (see security.mdx §Idempotency) means the cache holds read responses too.", "default": false }, "context": { diff --git a/static/schemas/source/governance/sync-plans-response.json b/static/schemas/source/governance/sync-plans-response.json index 9387284962..ebc74452d3 100644 --- a/static/schemas/source/governance/sync-plans-response.json +++ b/static/schemas/source/governance/sync-plans-response.json @@ -107,7 +107,7 @@ }, "replayed": { "type": "boolean", - "description": "Set to true when this response is a cached replay returned for an idempotency_key that was already processed. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, and any downstream system that assumes exactly-once event semantics. Only present on responses to mutating requests that carry idempotency_key.", + "description": "Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. From 3.1 onward, `replayed` MAY appear on responses to any request that resolved via the idempotency cache, including read tools — universal `idempotency_key` (see security.mdx §Idempotency) means the cache holds read responses too.", "default": false }, "context": { diff --git a/static/schemas/source/media-buy/get-products-request.json b/static/schemas/source/media-buy/get-products-request.json index d708c168be..7316c2d0ea 100644 --- a/static/schemas/source/media-buy/get-products-request.json +++ b/static/schemas/source/media-buy/get-products-request.json @@ -25,7 +25,7 @@ }, "refine": { "type": "array", - "description": "Array of change requests for iterating on products and proposals from a previous get_products response. Each entry declares a scope (request, product, or proposal) and what the buyer is asking for. Only valid when buying_mode is 'refine'. The seller responds to each entry via refinement_applied in the response, matched by position.", + "description": "Array of change requests for iterating on products and proposals from a previous get_products response. Each entry declares a scope (request, product, or proposal) and what the buyer is asking for. Only valid when buying_mode is 'refine'. The seller responds to each entry via refinement_applied in the response, matched by position.\n\nFinalize-exclusivity rule: if any entry has `action: 'finalize'`, ALL entries in the array MUST be proposal-scoped with `action: 'finalize'` — mixing finalize entries with `include`/`omit` entries or with request- / product-scoped entries MUST be rejected by the seller with `INVALID_REQUEST`. Finalize is a commit, not a refinement; the buyer expressing intent to commit means refinements have already converged. Buyers needing to refine AND commit in close succession sequence the calls: first a refine call (no finalize), then a finalize call against the resulting `proposal_id`(s).\n\nMulti-finalize semantics: multiple finalize entries against different `proposal_id` values in a single call are allowed and MUST be **atomic at the observation point** — sellers MUST NOT return a success response unless every named proposal has both completed and been persisted as committed. Pre-commit validation runs before any side-effects (inventory pull, terms lock, governance attestation); if any proposal fails validation, the seller MUST reject the entire call without committing any of the named proposals. There is no rollback operation in the spec — an `unfinalize` would itself be a new mutation surface; the atomicity guarantee runs entirely on the seller's pre-commit validation gate, not on post-commit reversal. Sellers that cannot guarantee atomic pre-commit validation MUST reject multi-finalize arrays with `MULTI_FINALIZE_UNSUPPORTED` (preferred — distinguishes seller-side capability gap from a malformed request) or `INVALID_REQUEST` (acceptable fallback for sellers on a pre-3.1 error catalog). If a mid-commit failure occurs *after* validation passed but before all proposals persist (e.g., a downstream ad server fails between commits one and two), the seller MUST return `INTERNAL_ERROR` with `refinement_applied[]` carrying per-position outcomes — the spec does NOT define a recovery path for this case, and buyers SHOULD treat the resulting state as undefined and re-read via `get_media_buys` / equivalent before retrying. Buyers MUST NOT assume multi-finalize support without a successful first attempt — there is no capability flag for this; the failure response is the discovery surface. Buyers whose intent specifically requires atomic commit (e.g., budget-shared proposals where one finalizing without the other is incoherent) MUST be prepared to abandon the intent if the seller returns `MULTI_FINALIZE_UNSUPPORTED` — there is no recovery for that loss of buyer intent beyond sequencing single-finalize calls and accepting the looser commit guarantee.", "minItems": 1, "items": { "type": "object", @@ -106,7 +106,7 @@ "finalize" ], "default": "include", - "description": "'include' (default): return this proposal with updated allocations and pricing. 'omit': exclude this proposal from the response. 'finalize': request firm pricing and inventory hold — transitions a draft proposal to committed with an expires_at hold window. May trigger seller-side approval (HITL). The buyer should not set a time_budget for finalize requests — they represent a commitment to wait for the result. Optional — when omitted, the seller treats the entry as action: 'include'." + "description": "'include' (default): return this proposal with updated allocations and pricing. 'omit': exclude this proposal from the response. 'finalize': request firm pricing and inventory hold — transitions a draft proposal to committed with an expires_at hold window. May trigger seller-side approval (HITL). The buyer should not set a time_budget for finalize requests — they represent a commitment to wait for the result. Optional — when omitted, the seller treats the entry as action: 'include'.\n\nFinalize is exclusive within the parent `refine[]` array: see the array-level description for the finalize-exclusivity rule (mixing finalize with non-finalize entries is rejected) and multi-finalize atomicity contract." }, "ask": { "type": "string", diff --git a/static/schemas/source/property/create-property-list-response.json b/static/schemas/source/property/create-property-list-response.json index 0677d3c4b9..b0748f8cc8 100644 --- a/static/schemas/source/property/create-property-list-response.json +++ b/static/schemas/source/property/create-property-list-response.json @@ -20,7 +20,7 @@ }, "replayed": { "type": "boolean", - "description": "Set to true when this response is a cached replay returned for an idempotency_key that was already processed. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, and any downstream system that assumes exactly-once event semantics. Only present on responses to mutating requests that carry idempotency_key.", + "description": "Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. From 3.1 onward, `replayed` MAY appear on responses to any request that resolved via the idempotency cache, including read tools — universal `idempotency_key` (see security.mdx §Idempotency) means the cache holds read responses too.", "default": false }, "context": { diff --git a/static/schemas/source/property/delete-property-list-response.json b/static/schemas/source/property/delete-property-list-response.json index 1405452ee3..b242383eb4 100644 --- a/static/schemas/source/property/delete-property-list-response.json +++ b/static/schemas/source/property/delete-property-list-response.json @@ -21,7 +21,7 @@ }, "replayed": { "type": "boolean", - "description": "Set to true when this response is a cached replay returned for an idempotency_key that was already processed. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, and any downstream system that assumes exactly-once event semantics. Only present on responses to mutating requests that carry idempotency_key.", + "description": "Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. From 3.1 onward, `replayed` MAY appear on responses to any request that resolved via the idempotency cache, including read tools — universal `idempotency_key` (see security.mdx §Idempotency) means the cache holds read responses too.", "default": false }, "context": { diff --git a/static/schemas/source/property/update-property-list-response.json b/static/schemas/source/property/update-property-list-response.json index b5b8a707ce..7373d6a31f 100644 --- a/static/schemas/source/property/update-property-list-response.json +++ b/static/schemas/source/property/update-property-list-response.json @@ -16,7 +16,7 @@ }, "replayed": { "type": "boolean", - "description": "Set to true when this response is a cached replay returned for an idempotency_key that was already processed. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, and any downstream system that assumes exactly-once event semantics. Only present on responses to mutating requests that carry idempotency_key.", + "description": "Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. From 3.1 onward, `replayed` MAY appear on responses to any request that resolved via the idempotency cache, including read tools — universal `idempotency_key` (see security.mdx §Idempotency) means the cache holds read responses too.", "default": false }, "context": {