Skip to content

RFC: seller declines have no structured outcome — add a Rejected arm (get_products first) and give task-status rejected a payload contract #6035

Description

@bokelley

Problem

A seller answering a brief has two structured outcomes available: products, or an error. There is no way to say "we are declining this brief, here is why, and here is what would change the answer."

That third outcome is ordinary commerce. A publisher declines because the budget is below what they will sell the inventory for, because they do not carry the channel that was asked for, or because the flight is sold out. None of those are errors — the request was well-formed, the seller understood it, and the seller made a commercial decision.

Today the only compliant home is the error channel. Per the GOVERNANCE_DENIED wire-placement rule, a task whose response defines no rejection arm puts the denial in errors[] + adcp_error and does flip transport failure markers (HTTP 4xx, MCP isError: true). So a declined brief is delivered to the buyer as INVALID_REQUEST — indistinguishable, at the dispatch layer, from a malformed request.

Two consequences:

  1. Buyers cannot dispatch on it. A buyer agent receiving INVALID_REQUEST should re-examine its own request. The correct response to a decline is the opposite: change the commercial terms and come back. Agents that treat 4xx as a bug will not negotiate, and agents that retry unchanged will get the same answer.
  2. Sellers who want to disclose have nowhere good to put it. "Your CPM is below what we can sell this inventory for — come back higher" is a negotiation move that invites a better bid. Sending it as the message on an INVALID_REQUEST envelope means many buyer agents will never surface it.

There is also a confidentiality hazard, which is what brought us here. With no structured place for a sanitized decline, the path of least resistance for an implementer is to pipe the composer's internal explanation into the error message — the merchandising rule that fired, the internal inventory ids it excluded, the signal vendor that was not live. That is seller-confidential material crossing to the counterparty in a negotiation. We found and fixed exactly that leak in our own implementation. A protocol that offers no sanitized decline shape makes the leaky version the default one.

The spec already wants this

Four observations, all from the published 3.1 surface:

1. task-status.json reserves rejected, and no arm gives it a payload. TaskStatus is 'submitted' | 'working' | 'input-required' | 'completed' | 'canceled' | 'failed' | 'rejected' | 'auth-required' | 'unknown'. The completed-response shapes type status as the full enum, so a seller can emit status: 'rejected' — but no schema defines what accompanies it. There is no reason, no suggestions, and no normative guidance. Two sellers emitting a rejected get_products today would produce two different payloads, and no buyer could rely on either. An enum value with no payload contract is an interop hazard, not a feature.

2. The doctrine is already written — only the arms are missing. From the GOVERNANCE_DENIED guidance:

"Governance denial is a structured business outcome, not a system error — the governance call SUCCEEDED and the agent returned a denial verdict… Transport-level success markers MUST NOT be flipped (HTTP 200, MCP isError: false, A2A succeeded) — the task ran successfully and produced a structured response… The rule generalizes to any current or future task whose response defines a discriminated rejection arm."

The general rule exists. Only two tasks have an arm to apply it to.

3. AcquireRightsRejected already solved the confidentiality problem. Its reason is documented as:

"Why the rights request was rejected. May be sanitized to protect confidential brand rules — e.g., 'This violates our public figures brand guidelines' rather than naming the specific rule."

and its suggestions:

"Actionable alternatives the buyer can try. If present, the rejection is fixable — the buyer can adjust their request. If absent, the rejection is final."

That is precisely the shape a declined brief needs, and the fixable/final signal is exactly what a buyer agent must branch on. CreativeRejected carries the same pair. The pattern is proven; it is just not available on the discovery surface.

4. get_products is already a status-discriminated union. GetProductsResponse | GetProductsAsyncWorking | GetProductsAsyncInputRequired | GetProductsAsyncSubmitted, discriminated by the task-level status literal. Adding a rejected arm extends an existing union rather than introducing discrimination — no new dispatch mechanism, and no conflict with the oneOf-discriminator audit (#3917, #3939).

Related but distinct: #3299 (normalize Submitted-arm coverage) is the same class of gap on the async side, and names get_products explicitly. This is its sibling on the rejection side. #5540 (negotiated pricing, closed COMPLETED) covers the case where the buyer asked for a quote; a decline on an unsolicited brief is upstream of that handshake and is not covered by it.

Proposal

Add GetProductsRejected to the get_products response union:

interface GetProductsRejected {
  // Task-level discriminator. Already a legal task-status value; this arm
  // gives it a payload contract for the first time.
  status: 'rejected'

  // Why the brief was declined, in market terms. MAY be sanitized to protect
  // confidential merchandising rules — "your budget is below what we can sell
  // this inventory for" rather than naming the rule, the internal inventory
  // id, or the data vendor. Plain text only.
  //
  // Buyers MUST treat this as untrusted seller input: escape before rendering
  // to HTML, and sanitize or isolate before passing into an LLM prompt context.
  // (Same warning already carried on GetProductsAsyncSubmitted.message.)
  reason: string

  // Actionable alternatives: raise the budget, other channels, other flight
  // dates. If present, the decline is fixable and the buyer can re-brief.
  // If absent, it is final for this brief.
  suggestions?: string[]

  context?: ContextObject
  ext?: ExtensionObject
}

Normative rules, all inherited from the existing rejection arms:

  • Sellers using this arm MUST NOT also emit adcp_error / errors[] (mirror the not: { required: [errors] } constraint that AcquireRightsRejected and CreativeRejected already declare).
  • Transport-level success markers MUST NOT be flipped: HTTP 200, MCP isError: false, A2A succeeded.
  • Buyers MUST dispatch on status first and fall back to adcp_error.code only when no rejection arm was used — the existing two-layer dispatch rule, unchanged.

Boundaries between adjacent shapes

Worth writing down explicitly, because these are currently conflated in the wild:

Situation Shape
Catalogue queried, nothing matched the filters; seller takes no position status: 'completed', products: []
Seller understood the brief and is declining it status: 'rejected' + reason
Request was malformed, unauthorized, or unsupported adcp_error, failure markers flipped
Seller needs time or a human before answering status: 'submitted' / 'working' (#3299)
Buyer asked for a quote and is negotiating price proposal / quote handshake (#5540)

An empty products: [] is not a substitute for a decline: it is indistinguishable from "your filters excluded everything" and gives the buyer nothing to branch on. An envelope-level message is not a substitute either — it is prose alongside an outcome, not an outcome.

Generalization

The arm shape is task-agnostic. The same { status: 'rejected', reason, suggestions? } triple applies wherever a seller declines a well-formed business ask — create_media_buy being the obvious next one, which today can only express a denial through the GOVERNANCE_DENIED case-2 path. We suggest landing it on get_products first, where declines are most frequent and least dangerous to get wrong, then generalizing.

Implementation experience

We implemented seller-controlled declines on a composition storefront: the publisher's merchandising rulebook decides whether a buyer is told why a brief did not land, the agent writes that decline in the seller's voice, and a mechanical screen drops any wording that quotes the rulebook or names an internal id before it can reach the buyer. The behaviour we wanted was already fully described by AcquireRightsRejected's reason + suggestions semantics.

The only thing we could not do was put it in the right envelope. It ships as adcp_error: INVALID_REQUEST with a sanitized message, because that is what the spec prescribes for a task with no rejection arm — which means a well-formed brief, correctly understood and deliberately declined, is labelled to the buyer as an invalid request.

Metadata

Metadata

Assignees

No one assigned

    Labels

    claude-triagedIssue has been triaged by the Claude Code triage routine. Remove to re-triage.media-buyIssue concerns the media-buy protocol domainneeds-wg-reviewBlocked on a working-group decision — surface in WG meeting agendasrfcProtocol change — auto-adds to roadmap boardschemaJSON Schema source-of-truth: definitions, codegen artifacts, validation, hygienespec / protocol

    Type

    No type

    Projects

    Status
    No status

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions