feat: add return policy extension (dev.ucp.shopping.policy.return) - #634
feat: add return policy extension (dev.ucp.shopping.policy.return)#634amithanda wants to merge 1 commit into
Conversation
Defines the dev.ucp.shopping.policy.return policy type on the core policies[] primitive (#572), adding pre-purchase, machine-readable return terms across catalog, cart, checkout, and order: - window { days, anchor } as a policy statement with an explicit anchor - supported_resolutions (what the buyer gets back) - methods[] with per-channel logistics fee - policy-level restocking_fee (deduction independent of channel) - final_sale flag for non-returnable items The base policies[] container, applies_to targeting, precedence, and messages[] disclosure are unchanged; the extension only structures the type-specific body, gated by an if/then on type so unknown policy types are never rejected. Incorporates PR #257 feedback (open-string vocabularies, collapsed $defs, explicit anchor, separated logistics vs restocking cost).
| "$id": "https://ucp.dev/schemas/shopping/policy_return.json", | ||
| "name": "dev.ucp.shopping.policy.return", | ||
| "title": "Return Policy Extension", | ||
| "description": "Defines the `dev.ucp.shopping.policy.return` policy type on the core `policies[]` primitive, adding return terms to catalog, cart, checkout, and order.", |
There was a problem hiding this comment.
This description could be more clear that the policy MAY advertise across one or more these capabilities, but it doesn't necessarily advertise across all of them.
| "$defs": { | ||
| "return_body": { | ||
| "title": "Return Policy", | ||
| "description": "Return terms for a `dev.ucp.shopping.policy.return` policy. All fields are optional.", |
There was a problem hiding this comment.
Nit: Drop "All fields are optional" as it's redundant. The schema itself communicates required/non-required.
| "final_sale": { | ||
| "type": "boolean", | ||
| "description": "When `true`, the covered items cannot be returned. When `false` or omitted, the items are returnable subject to the other fields." | ||
| }, |
There was a problem hiding this comment.
If the item cannot be returned, and final_sale obviates the others fields.
Should we lean more into structure:
{ "final_sale": { type: "boolean" } }
{ "final_sale": { type: "boolean", const: true } }
{
"final_sale": { type: "boolean", const: false },
"window": ...
}
This makes the schema explicitly self-documenting and avoids contradictory states.
| "days": { | ||
| "type": "integer", | ||
| "minimum": 0, | ||
| "description": "Length of the return window in whole days from `anchor`. `0` means non-returnable." |
There was a problem hiding this comment.
How does days == 0 relate to final_sale being true?
| "amount": { | ||
| "$ref": "../common/types/amount.json", | ||
| "description": "Flat channel fee in ISO 4217 minor units, when `type` is `fixed_fee`." | ||
| }, |
There was a problem hiding this comment.
Consider splitting out a fixed_fee type and have amount live on it:
"fixed_fee": {
"title": "Fixed Fee Return Method Fee",
"description": "Fixed logistics fee charged for a return channel.",
"type": "object",
"allOf": [{ "$ref": "#/$defs/fee" }],
"properties": {
"type": {
"type": "string",
"const": "fixed_fee"
},
"amount": {
"$ref": "../common/types/amount.json",
"description": "Flat channel fee in ISO 4217 minor units. Required when type is fixed_fee."
}
}
}This does not change the contract of the base fee but relatedness between type=fixed_fee and amount is now explicitly captured by the schema.
| "$ref": "types/policy.json" | ||
| }, | ||
| { | ||
| "if": { |
There was a problem hiding this comment.
I flagged similar usage of if/then blocks in #688 (comment) and there suggested we avoid it entirely. This is primarily motivated by (i) readability of the schema and (ii) interoperability with code generation.
Is there a reason to not have a return_policies_item that inherits from "#/types/policy"?
In _response definitions you would change:
"policies": {
"type": "array",
"items": {
"anyOf": [
{ "$ref": "#/types/policy" }
]
}
}
I believe the primary driver of the allOf/if/then pattern is decentralized extensibility, but I believe that can be achieved using this approach as well.
Defines the body of the
dev.ucp.shopping.policy.returnpolicy type on the corepolicies[]primitive (#572), adding pre-purchase, machine-readable return termsacross catalog, cart, checkout, and order. The primitive's container,
applies_totargeting, same-type precedence, and
messages[]disclosure are unchanged; thisextension only structures the type-specific body.
Context
policies[]primitive landed in feat: add policies[] to cart, checkout, catalog, and order #572, which already listsdev.ucp.shopping.policy.returnas a well-known type inoverview.mdwith anempty body, explicitly left for this PR to define. This is not a new namespace
claim; it realizes an already-documented type.
return type on top of it. It supersedes the standalone structure in feat: add Return Extension to UCP specification #257 (a
return_policiesmap keyed by id plusreturn_policy_idon line items), whichthe primitive's
applies_totargeting replaced.What this adds
source/schemas/shopping/policy_return.json- the extension. Composes ontocatalog.search,catalog.lookup,cart,checkout, andorder.docs/specification/return.md- the spec.mkdocs.yml- nav entry and llms.txt section.Five type-specific fields, all optional (the base policy already guarantees
typeand
description):final_sale- non-returnable flag.window{ days, anchor }- the return window as a policy statement.supported_resolutions[]- what the buyer gets back (money, store credit, exchange).methods[]- return channels, each with its logisticsfee.restocking_fee- a refund deduction independent of channel.Design decisions
if type == ... then <body>. Becausethe base policy sets
additionalProperties: true, a platform that has notnegotiated the type still renders the policy from
type+description, andunknown policy types are never rejected. The container is untouched.
applies_to, not item -> policy ids (the feat: add policies[] to cart, checkout, catalog, and order #572decision). A response-wide default is one entry with no
applies_to; overridestarget specific items; precedence is inherited from the primitive.
it is needed to compute or compare on (not merely display) and reduces to a
shape that is closed-and-final (boolean, integer) or open-by-design (string
vocab), so future additions stay non-breaking. Everything else stays in
description/urluntil its shape settles.methods[].feevs. apolicy-level
restocking_fee. This resolves the limitation raised in feat: add Return Extension to UCP specification #257(§1.4): "free returns by mail with a restocking fee" is inexpressible when a
single per-method fee must be
freexorfixed_fee.window.anchor, no default. Anchors genuinely vary, sowindowrequires both
daysandanchor.anchor,supported_resolutions,method.type,fee.type), neverenum, so new channels and outcomes arenon-breaking additions.
Potential future additions
Fields we may add to this type later, once their shape settles. Each would be an
optional, non-breaking addition; until then the information is carried in
description/url:window.deadline- the absolute cutoff, computed once the anchorevent (e.g., delivery) fires; added with the Order-side resolution work.
damaged / refurbished). The strongest near-term candidate.
defect.
(e.g., shipping + handling).
from "not stated structurally."
Non-goals
Out of scope for this type; they belong to other extensions:
execution, and reason/condition capture at return time. A separate future
extension; this type must not grow those fields.
bookings/events. A sibling
dev.ucp.shopping.policy.cancellationtype over thesame primitive.
Category
Related Issues
Builds on #572 (policies primitive). Supersedes the standalone approach in #257.
Checklist
!).return.md, mkdocs nav, llms.txt).ucp:exampleblocks inreturn.md, checked by the corpus example validator).