Summary
The Discount extension (dev.ucp.shopping.discount) returns successfully applied discounts as structured objects in discounts.applied[], but reports rejected discount codes only through the generic messages[] array. A Platform that submits several codes has to filter messages[] for discount warnings and parse the JSONPath in path ($.discounts.codes[1]) to work out which code failed and why.
This proposal adds an optional, response-only discounts.inapplicable[] array alongside codes and applied. Each entry binds one rejected code to a machine-readable reason (reusing the existing discount_code_* vocabulary) and a human-readable content. The change is purely additive. messages[] remains the backward-compatible channel and Businesses are asked to write both during the transition. The result is a complete, structured lifecycle for a submitted code: codes (what the Platform sent), applied (what worked), inapplicable (what did not, and why).
Motivation
The problem
In agent-driven commerce, discount code application is a high-frequency, multi-code operation. Agents try codes on the buyer's behalf, stack promotions, retry after cart changes, and explain outcomes back to the buyer. Today the success side of that loop is structured (applied[]) but the failure side is not. To learn which code failed, an agent must:
- filter
messages[], which also carries fulfillment, identity, payment, 3DS, consent and loyalty messages;
- parse
path to recover an array index into discounts.codes;
- re-associate that index with the code string it originally submitted.
This is brittle across implementations (path formats vary, indices shift when codes are replaced, some Businesses emit one message for several codes) and it forces every Platform to write the same parsing code.
Why now
v2026-08-25 moved the protocol decisively toward structured, agent-readable outcomes: earning_forecast in Loyalty, response-carried $requestConstraints, the actions[] primitive, policies[] snapshots. Discount rejection is the one remaining high-frequency incentives path that still requires text and path scraping. PR #271 showed implementer demand and a working schema; the release did not close the gap. PR #609 is adding a structured available[] array to the same object for the "before" side of the code lifecycle, so completing the "after" side now keeps the object coherent.
Use cases enabled
- An agent submits
["SUMMER20", "EXPIRED50", "VIP_ONLY"] and can say, without parsing: "SUMMER20 applied (−$8.00). EXPIRED50 expired. VIP_ONLY is for members only."
- A Platform renders a per-code status list in its checkout UI directly from the discounts object.
- An agent detects
discount_code_combination_disallowed and offers to swap codes to maximize savings.
- A Platform carrying codes from cart to checkout learns which ones stopped being valid at checkout time, in the same structured shape.
Beneficiaries
Platforms and shopping agents first (deterministic code-to-outcome mapping). Businesses second (clearer debugging of stacking and eligibility conflicts, fewer support escalations). Buyers third (accurate "this code did not apply because…" copy).
Goals
- Add an optional
discounts.inapplicable array to discounts_object in source/schemas/shopping/discount.json, available on both Cart and Checkout.
- Define an
inapplicable_discount object with exactly three required fields: code, reason, content.
- Reuse
common/types/error_code.json for reason, with the six existing discount_code_* codes as the SHOULD vocabulary. No new error codes.
- Keep the change strictly additive. A client that reads only
applied and messages[] keeps working unchanged.
- Specify dual-write guidance: when a code is rejected, a Business SHOULD emit both an
inapplicable[] entry and the matching messages[] warning.
- Scope
inapplicable to code-based rejections only. Automatic discounts, eligibility claims and loyalty benefits keep their existing messages[] codes.
- State the partition rule: each submitted code SHOULD appear in exactly one of
applied[].code or inapplicable[].code.
- Ship spec text and CI-validated examples covering mixed success/failure, combination conflict, all-rejected, and cart-to-checkout invalidation.
Non-Goals
- Deprecating or changing
messages[]. It stays the general-purpose channel and the backward-compatible channel for rejected codes.
- Structured rejection for automatic discounts,
context.eligibility claims, or loyalty benefits. These already have message codes (eligibility_invalid, membership_benefit_ineligible) and different semantics (no code to bind to).
- New error codes. If the TC wants, for example,
discount_code_not_yet_active, that is a separate one-line follow-up.
- Discount discovery (
available[], PR #609 / EP #707), promotion reservation or lock lifecycles, or any new operation. This EP does not depend on #609 and #609 does not depend on this EP; they are complementary.
- Changing the replacement semantics of
discounts.codes, totals math, or allocations.
- Any vendor-specific promotion engine concept or transport-specific (MCP, REST, A2A) shape.
Detailed Design
API changes
None. No new operations, endpoints, or capability names. Cart create/update and Checkout create/update/get keep their signatures. The Discount extension's response object gains one optional field. Discovery is unchanged: a Business that advertises dev.ucp.shopping.discount at a version that includes this field MAY emit it.
Data structures
All changes are in source/schemas/shopping/discount.json.
New definition #/$defs/inapplicable_discount (inline, matching how applied_discount and allocation are defined in the same file):
"inapplicable_discount": {
"type": "object",
"description": "A submitted discount code that could not be applied, with a structured rejection reason.",
"required": ["code", "reason", "content"],
"properties": {
"code": {
"type": "string",
"description": "The submitted discount code that was rejected, as echoed in discounts.codes."
},
"reason": {
"$ref": "../common/types/error_code.json",
"description": "Machine-readable rejection reason. SHOULD be one of the standard discount rejection codes: discount_code_expired, discount_code_invalid, discount_code_already_applied, discount_code_combination_disallowed, discount_code_user_not_logged_in, discount_code_user_ineligible. Freeform codes are permitted for business-specific reasons."
},
"content": {
"type": "string",
"description": "Human-readable explanation suitable for display to the buyer (e.g., \"Code 'EXPIRED50' expired on December 1st\")."
}
}
}
Modified definition #/$defs/discounts_object, one new property:
"inapplicable": {
"type": "array",
"items": { "$ref": "#/$defs/inapplicable_discount" },
"description": "Submitted discount codes that could not be applied, each with a structured rejection reason. Complements messages[] by binding rejections directly to codes. Never includes automatic discounts.",
"ucp_request": "omit"
}
codes (request and response echo) and applied (response, ucp_request: "omit") are unchanged. The Cart and Checkout compositions (dev.ucp.shopping.cart, dev.ucp.shopping.checkout) need no edits because they reference discounts_object.
Field naming: the rejection reason is called reason, not code, to avoid colliding with the discount code field on the same object. messages[] keeps using code for the error code, as today.
Behavioral changes
Response behavior when a submitted code cannot be applied:
- The Business SHOULD add one
inapplicable[] entry for that code, using the most specific applicable discount_code_* reason. One entry per rejected code.
- The Business SHOULD also add the corresponding
messages[] warning (type: "warning", same code as reason, same content, path pointing at the discounts.codes index), so Platforms that do not yet read inapplicable keep working. This dual-write guidance is expected to be relaxed once Platform support is broad (see Graduation Criteria).
- The rejected code MUST still be echoed in
discounts.codes (existing behavior) and MUST NOT appear in applied[].
inapplicable[] MUST only contain code-based rejections. Automatic discounts, eligibility claims and loyalty benefits that do not apply continue to use messages[] only.
- Partition rule: each code in
discounts.codes SHOULD appear in exactly one of applied[].code or inapplicable[].code. Businesses SHOULD NOT silently drop a code without reporting it in one of the two.
- Entries SHOULD follow the order of the submitted
codes array.
- Cart-to-checkout continuity: when carried-forward codes stop being valid at checkout time (expired, ineligible), the Business SHOULD report them in
inapplicable[] as well as in messages[], using the same standard reasons.
- Totals,
line_items[].totals[], and applied[] continue to reflect only successful discounts.
Nothing changes for a Business that does not emit inapplicable. Nothing changes for a Platform that ignores it.
Example: mixed applied and inapplicable codes
Request (cart update):
{
"discounts": {
"codes": ["SUMMER20", "EXPIRED50", "VIP_ONLY"]
}
}
Response (abbreviated):
{
"discounts": {
"codes": ["SUMMER20", "EXPIRED50", "VIP_ONLY"],
"applied": [
{
"code": "SUMMER20",
"title": "Summer Sale 20% Off",
"amount": 800,
"method": "each",
"allocations": [{ "path": "$.line_items[0]", "amount": 800 }]
}
],
"inapplicable": [
{
"code": "EXPIRED50",
"reason": "discount_code_expired",
"content": "Code 'EXPIRED50' expired on December 1st"
},
{
"code": "VIP_ONLY",
"reason": "discount_code_user_ineligible",
"content": "This code is available to VIP members only"
}
]
},
"totals": [
{ "type": "subtotal", "display_text": "Subtotal", "amount": 4000 },
{ "type": "items_discount", "display_text": "Item Discounts", "amount": -800 },
{ "type": "total", "display_text": "Total", "amount": 3200 }
],
"messages": [
{
"type": "warning",
"code": "discount_code_expired",
"path": "$.discounts.codes[1]",
"content": "Code 'EXPIRED50' expired on December 1st"
},
{
"type": "warning",
"code": "discount_code_user_ineligible",
"path": "$.discounts.codes[2]",
"content": "This code is available to VIP members only"
}
]
}
Example: combination conflict
{
"discounts": {
"codes": ["BOGO50", "FLAT20"],
"applied": [
{ "code": "BOGO50", "title": "Buy One Get One 50% Off", "amount": 1500, "priority": 1 }
],
"inapplicable": [
{
"code": "FLAT20",
"reason": "discount_code_combination_disallowed",
"content": "FLAT20 cannot be combined with BOGO50. One promotional code per order."
}
]
}
}
Specification text changes
In docs/specification/shopping/extensions/discount.md:
- "Operations → Response behavior": add "Rejected codes additionally listed in
discounts.inapplicable (see below)" and extend the cart-to-checkout continuity paragraph.
- New section "Inapplicable Discounts" directly after "Rejected Codes", containing the behavioral rules above, the
{{ extension_schema_fields(...) }} table for inapplicable_discount, and the two examples.
- "Examples → Rejected discount code": extend the existing response example with an
inapplicable[] block so the canonical example shows both channels.
Risks and Mitigations
Security
Risk: none new. The array repeats codes the Platform already submitted and reasons already permitted in messages[]. No PII, no new identifiers, no request-side field. Mitigation: content remains Business-authored display text with the same guidance as messages[].content; reason is constrained to the existing error-code type.
Performance
Risk: negligible. One small array, bounded by the number of submitted codes, produced from validation work the Business already performs. Mitigation: no extra round-trips, no new operation. Dual-write doubles a handful of small entries during transition only.
Backward compatibility
Risk: none for existing clients. The field is optional, response-only (ucp_request: "omit"), and unknown properties are ignored. Risk: two channels can drift (different reason on inapplicable vs messages). Mitigation: spec requires the paired message to carry the same code and content; CI-validated examples show the dual-write form; conformance does not require inapplicable while the field is Working Draft. Migration plan: dual-write while Working Draft and Candidate; revisit whether messages[] duplication can become optional at Stable.
Complexity
Risk: a second place to look for rejection state. Mitigation: one definition, one field, one spec section; reuse of error_code.json and the existing vocabulary; no new capability, operation, or version negotiation. The object becomes more regular, not less, because success and failure are now symmetric.
Semantic ambiguity
Risk: implementers unsure whether automatic or eligibility-based non-application belongs here. Mitigation: explicit MUST NOT for anything without a submitted code; pointers to eligibility_invalid and membership_benefit_ineligible as the existing channels for those cases.
Test Plan
Unit tests (schema level).
ucp-schema lint source/ passes with the new $defs entry and $ref to ../common/types/error_code.json.
- A minimal
inapplicable_discount with code, reason, content validates; each of the three fields missing fails.
reason accepts every standard discount_code_* value and a freeform string.
ucp_request: "omit" is honored: inapplicable is stripped from derived request schemas for cart create/update and checkout create/update, and remains in response schemas.
Integration tests (composition and documentation).
scripts/validate_examples.py --schema-base source/schemas/ validates every new spec example against the composed shopping/cart and shopping/checkout schemas, covering: mixed applied + inapplicable; all codes inapplicable with empty applied; codes: [] with no inapplicable; automatic-only applied with no inapplicable; the dual-write example with matching messages[].
scripts/test_validate_examples.py, markdownlint, cspell, yamllint and the strict mkdocs build stay green.
End-to-end tests (reference implementation).
Automated against at least one Business implementation exposing the Discount extension:
- Expired code alone:
applied empty, one inapplicable entry with discount_code_expired.
- Invalid code plus valid code: one applied, one
discount_code_invalid.
- Two mutually exclusive codes: one applied, one
discount_code_combination_disallowed.
- Member-only code without identity:
discount_code_user_not_logged_in; after identity is provided, the same code moves from inapplicable to applied.
- Cart-to-checkout: a code applied on the cart expires before checkout creation; checkout response lists it in
inapplicable and in messages[].
- Platform-side assertion in each scenario: the applied/inapplicable partition is derived without reading
messages[].path, and matches what messages[] reports.
Graduation Criteria
Working Draft → Candidate:
Candidate → Stable:
Implementation History
- 2026-03-18: Design drafted against the 2026-01-23 discount schema.
- 2026-03-18: PR #271 opened with schema, spec section and examples.
- 2026-08-05: PR #271 closed pending an Enhancement Proposal (Significant Change).
- 2026-09-11: Proposal submitted.
Code of Conduct
Summary
The Discount extension (
dev.ucp.shopping.discount) returns successfully applied discounts as structured objects indiscounts.applied[], but reports rejected discount codes only through the genericmessages[]array. A Platform that submits several codes has to filtermessages[]for discount warnings and parse the JSONPath inpath($.discounts.codes[1]) to work out which code failed and why.This proposal adds an optional, response-only
discounts.inapplicable[]array alongsidecodesandapplied. Each entry binds one rejected code to a machine-readablereason(reusing the existingdiscount_code_*vocabulary) and a human-readablecontent. The change is purely additive.messages[]remains the backward-compatible channel and Businesses are asked to write both during the transition. The result is a complete, structured lifecycle for a submitted code:codes(what the Platform sent),applied(what worked),inapplicable(what did not, and why).Motivation
The problem
In agent-driven commerce, discount code application is a high-frequency, multi-code operation. Agents try codes on the buyer's behalf, stack promotions, retry after cart changes, and explain outcomes back to the buyer. Today the success side of that loop is structured (
applied[]) but the failure side is not. To learn which code failed, an agent must:messages[], which also carries fulfillment, identity, payment, 3DS, consent and loyalty messages;pathto recover an array index intodiscounts.codes;This is brittle across implementations (path formats vary, indices shift when codes are replaced, some Businesses emit one message for several codes) and it forces every Platform to write the same parsing code.
Why now
v2026-08-25 moved the protocol decisively toward structured, agent-readable outcomes:
earning_forecastin Loyalty, response-carried$requestConstraints, theactions[]primitive,policies[]snapshots. Discount rejection is the one remaining high-frequency incentives path that still requires text and path scraping. PR #271 showed implementer demand and a working schema; the release did not close the gap. PR #609 is adding a structuredavailable[]array to the same object for the "before" side of the code lifecycle, so completing the "after" side now keeps the object coherent.Use cases enabled
["SUMMER20", "EXPIRED50", "VIP_ONLY"]and can say, without parsing: "SUMMER20 applied (−$8.00). EXPIRED50 expired. VIP_ONLY is for members only."discount_code_combination_disallowedand offers to swap codes to maximize savings.Beneficiaries
Platforms and shopping agents first (deterministic code-to-outcome mapping). Businesses second (clearer debugging of stacking and eligibility conflicts, fewer support escalations). Buyers third (accurate "this code did not apply because…" copy).
Goals
discounts.inapplicablearray todiscounts_objectinsource/schemas/shopping/discount.json, available on both Cart and Checkout.inapplicable_discountobject with exactly three required fields:code,reason,content.common/types/error_code.jsonforreason, with the six existingdiscount_code_*codes as the SHOULD vocabulary. No new error codes.appliedandmessages[]keeps working unchanged.inapplicable[]entry and the matchingmessages[]warning.inapplicableto code-based rejections only. Automatic discounts, eligibility claims and loyalty benefits keep their existingmessages[]codes.applied[].codeorinapplicable[].code.Non-Goals
messages[]. It stays the general-purpose channel and the backward-compatible channel for rejected codes.context.eligibilityclaims, or loyalty benefits. These already have message codes (eligibility_invalid,membership_benefit_ineligible) and different semantics (no code to bind to).discount_code_not_yet_active, that is a separate one-line follow-up.available[], PR #609 / EP #707), promotion reservation or lock lifecycles, or any new operation. This EP does not depend on #609 and #609 does not depend on this EP; they are complementary.discounts.codes, totals math, or allocations.Detailed Design
API changes
None. No new operations, endpoints, or capability names. Cart create/update and Checkout create/update/get keep their signatures. The Discount extension's response object gains one optional field. Discovery is unchanged: a Business that advertises
dev.ucp.shopping.discountat a version that includes this field MAY emit it.Data structures
All changes are in
source/schemas/shopping/discount.json.New definition
#/$defs/inapplicable_discount(inline, matching howapplied_discountandallocationare defined in the same file):Modified definition
#/$defs/discounts_object, one new property:codes(request and response echo) andapplied(response,ucp_request: "omit") are unchanged. The Cart and Checkout compositions (dev.ucp.shopping.cart,dev.ucp.shopping.checkout) need no edits because they referencediscounts_object.Field naming: the rejection reason is called
reason, notcode, to avoid colliding with the discountcodefield on the same object.messages[]keeps usingcodefor the error code, as today.Behavioral changes
Response behavior when a submitted code cannot be applied:
inapplicable[]entry for that code, using the most specific applicablediscount_code_*reason. One entry per rejected code.messages[]warning (type: "warning", samecodeasreason, samecontent,pathpointing at thediscounts.codesindex), so Platforms that do not yet readinapplicablekeep working. This dual-write guidance is expected to be relaxed once Platform support is broad (see Graduation Criteria).discounts.codes(existing behavior) and MUST NOT appear inapplied[].inapplicable[]MUST only contain code-based rejections. Automatic discounts, eligibility claims and loyalty benefits that do not apply continue to usemessages[]only.discounts.codesSHOULD appear in exactly one ofapplied[].codeorinapplicable[].code. Businesses SHOULD NOT silently drop a code without reporting it in one of the two.codesarray.inapplicable[]as well as inmessages[], using the same standard reasons.line_items[].totals[], andapplied[]continue to reflect only successful discounts.Nothing changes for a Business that does not emit
inapplicable. Nothing changes for a Platform that ignores it.Example: mixed applied and inapplicable codes
Request (cart update):
{ "discounts": { "codes": ["SUMMER20", "EXPIRED50", "VIP_ONLY"] } }Response (abbreviated):
{ "discounts": { "codes": ["SUMMER20", "EXPIRED50", "VIP_ONLY"], "applied": [ { "code": "SUMMER20", "title": "Summer Sale 20% Off", "amount": 800, "method": "each", "allocations": [{ "path": "$.line_items[0]", "amount": 800 }] } ], "inapplicable": [ { "code": "EXPIRED50", "reason": "discount_code_expired", "content": "Code 'EXPIRED50' expired on December 1st" }, { "code": "VIP_ONLY", "reason": "discount_code_user_ineligible", "content": "This code is available to VIP members only" } ] }, "totals": [ { "type": "subtotal", "display_text": "Subtotal", "amount": 4000 }, { "type": "items_discount", "display_text": "Item Discounts", "amount": -800 }, { "type": "total", "display_text": "Total", "amount": 3200 } ], "messages": [ { "type": "warning", "code": "discount_code_expired", "path": "$.discounts.codes[1]", "content": "Code 'EXPIRED50' expired on December 1st" }, { "type": "warning", "code": "discount_code_user_ineligible", "path": "$.discounts.codes[2]", "content": "This code is available to VIP members only" } ] }Example: combination conflict
{ "discounts": { "codes": ["BOGO50", "FLAT20"], "applied": [ { "code": "BOGO50", "title": "Buy One Get One 50% Off", "amount": 1500, "priority": 1 } ], "inapplicable": [ { "code": "FLAT20", "reason": "discount_code_combination_disallowed", "content": "FLAT20 cannot be combined with BOGO50. One promotional code per order." } ] } }Specification text changes
In
docs/specification/shopping/extensions/discount.md:discounts.inapplicable(see below)" and extend the cart-to-checkout continuity paragraph.{{ extension_schema_fields(...) }}table forinapplicable_discount, and the two examples.inapplicable[]block so the canonical example shows both channels.Risks and Mitigations
Security
Risk: none new. The array repeats codes the Platform already submitted and reasons already permitted in
messages[]. No PII, no new identifiers, no request-side field. Mitigation:contentremains Business-authored display text with the same guidance asmessages[].content;reasonis constrained to the existing error-code type.Performance
Risk: negligible. One small array, bounded by the number of submitted codes, produced from validation work the Business already performs. Mitigation: no extra round-trips, no new operation. Dual-write doubles a handful of small entries during transition only.
Backward compatibility
Risk: none for existing clients. The field is optional, response-only (
ucp_request: "omit"), and unknown properties are ignored. Risk: two channels can drift (different reason oninapplicablevsmessages). Mitigation: spec requires the paired message to carry the samecodeandcontent; CI-validated examples show the dual-write form; conformance does not requireinapplicablewhile the field is Working Draft. Migration plan: dual-write while Working Draft and Candidate; revisit whethermessages[]duplication can become optional at Stable.Complexity
Risk: a second place to look for rejection state. Mitigation: one definition, one field, one spec section; reuse of
error_code.jsonand the existing vocabulary; no new capability, operation, or version negotiation. The object becomes more regular, not less, because success and failure are now symmetric.Semantic ambiguity
Risk: implementers unsure whether automatic or eligibility-based non-application belongs here. Mitigation: explicit MUST NOT for anything without a submitted code; pointers to
eligibility_invalidandmembership_benefit_ineligibleas the existing channels for those cases.Test Plan
Unit tests (schema level).
ucp-schema lint source/passes with the new$defsentry and$refto../common/types/error_code.json.inapplicable_discountwithcode,reason,contentvalidates; each of the three fields missing fails.reasonaccepts every standarddiscount_code_*value and a freeform string.ucp_request: "omit"is honored:inapplicableis stripped from derived request schemas for cart create/update and checkout create/update, and remains in response schemas.Integration tests (composition and documentation).
scripts/validate_examples.py --schema-base source/schemas/validates every new spec example against the composedshopping/cartandshopping/checkoutschemas, covering: mixed applied + inapplicable; all codes inapplicable with emptyapplied;codes: []with noinapplicable; automatic-onlyappliedwith noinapplicable; the dual-write example with matchingmessages[].scripts/test_validate_examples.py, markdownlint, cspell, yamllint and the strictmkdocsbuild stay green.End-to-end tests (reference implementation).
Automated against at least one Business implementation exposing the Discount extension:
appliedempty, oneinapplicableentry withdiscount_code_expired.discount_code_invalid.discount_code_combination_disallowed.discount_code_user_not_logged_in; after identity is provided, the same code moves frominapplicabletoapplied.inapplicableand inmessages[].messages[].path, and matches whatmessages[]reports.Graduation Criteria
Working Draft → Candidate:
inapplicable[]with dual-writemessages[], and at least one Platform consuming it without JSONPath parsing.Candidate → Stable:
messages[]stays SHOULD or becomes optional.Implementation History
Code of Conduct