feat: add policies[] to cart, checkout, and catalog#572
Conversation
Introduce `policies[]`, a core response field carrying business rules about the items — return/refund terms, warranty, and custom policies — on cart, checkout, and catalog (search, lookup, get_product) responses. Entries target items with RFC 9535 JSONPath via `applies_to` (omitted = whole response; most-specific match wins). Key design decisions: - Core, not an extension: policies are facts the business expresses directly; reading them requires no capability negotiation. - Cross-surface: the same field applies on catalog, cart, and checkout responses. - Open, reverse-DNS `type` vocabulary (`dev.ucp.shopping.policy.refund`, `.warranty`; custom types in the author's own domain). Platforms tolerate unknown types — no closed enum, no central registry, no namespace collisions. - Open envelope + discriminated overlay: the base validates any policy; `refund` layers structured fields (`window` xor `final_sale`, mutually exclusive) via an allOf if/then. Adding a well-known overlay is additive. - `type` + `description` are the required floor (description MUST, stated in prose) so any policy is presentable without modeling its type. No `id`: policies are referenced positionally and by `url`, matching `discount`. - Disclosure lives in messages, not policies — a policy never forces display. When a term must be shown, the business emits a `messages[]` warning with `presentation: "disclosure"`, linked to its policy by `code == type` at the same target. One forcing channel, consistent with existing Prop 65 notices. - Complementary to `links[]`: policies are the structured layer, `links[]` the always-present fallback; a policy omitting `url` resolves the `links[]` entry of the corresponding type.
|
While working through the
Three properties recur across all of these:
The proposed The motivating example is cancellation policy for time-bound purchases — arguably the services-side counterpart of what the Return Extension (#257) covers for physical goods, and directly relevant to the Services Vertical discussion (#303). Since the refund fields are flagged as an initial sketch rather than a finalized set, here is a minimal shape that stays entirely inside this PR's architecture (a policy type in the container, augmenting {
"type": "dev.ucp.shopping.policy.cancellation",
"description": { "plain": "Free cancellation until Aug 13; 70% back until Aug 17; 30% until Aug 19; none after." },
"anchor": "2026-08-20T15:00:00Z",
"tiers": [
{ "until": "P7D", "buyer_bps": 10000 },
{ "until": "PT72H", "buyer_bps": 7000 },
{ "until": "PT24H", "buyer_bps": 3000 }
],
"after_last_tier_bps": 0
}( Two questions for the authors:
Happy to draft the schema addition and examples if a tiered construct is welcome. |
|
@yairsabag this is a great stress test and case study. UCP's key role here is to define a common primitive ( What you're describing — a tiered, anchor-relative, partial-outcome return/cancellation strategy — is a textbook case for exactly that. It can compose cleanly as an extension over the primitive: no new machinery, still riding Q1: a tiered construct is in scope, and it doesn't need to be forced into A sketch of what you outlined, as an extension. The type's body is key part, the rest is JSON Schema plumbing: // com.example.policy.cancellation — body
{
"type": "object",
"properties": {
"anchor": { "type": "string", "format": "date-time" },
"tiers": {
"type": "array",
"items": {
"type": "object",
"required": ["until", "buyer_bps"],
"properties": {
"until": { "type": "string", "description": "ISO 8601 duration before anchor" },
"buyer_bps": { "type": "integer", "minimum": 0, "maximum": 10000 }
}
}
},
"after_last_tier_bps": { "type": "integer", "minimum": 0, "maximum": 10000, "default": 0 }
}
}The extension then layers this onto Full example: https://gist.github.com/igrigorik/29062425c8b2437053936bb988cc2bbf Then on the wire it's just another entry in the same array — your exact example, unchanged: "policies": [
{
"type": "com.example.policy.cancellation",
"description": { "plain": "Free cancellation until Aug 13; 70% back until Aug 17; 30% until Aug 19; none after." },
"applies_to": ["$.line_items[0]"],
"anchor": "2026-08-20T15:00:00Z",
"tiers": [
{ "until": "P7D", "buyer_bps": 10000 },
{ "until": "PT72H", "buyer_bps": 7000 },
{ "until": "PT24H", "buyer_bps": 3000 }
],
"after_last_tier_bps": 0
}
]A platform that negotiated the extension validates and reasons over This is the division of labor we want: core stays small — the Q2: for a self-contained extension type I'd keep |
|
Thank you, this is exactly the guidance I needed, and the composition |
|
Quick follow-up: I validated the sketched shape against ~40 real cancellation Smallest evolution that covers the deterministic majority (~93% of the corpus): "rules": {
"buyer_cancel": { "tiers": [ { "until": "PT72H", "outcome": { "kind": "percentage", "buyer_bps": 7000 } } ] },
"no_show": { "kind": "unit_deduction", "seller_keeps": { "quantity": 1, "unit": "night" } },
"seller_cancel": { "kind": "percentage", "buyer_bps": 10000 }
}Updated schema, corpus notes and validated examples: Two things deliberately kept out: composite outcomes (the Israeli statutory |
|
Hi @igrigorik, Would love to discuss the following 2 design considerations and the associated tradeoffs. I think the tradeoffs boil down to ambiguity and principles around making something core v/s extension. Is JSONPath is the right mechanism for binding a policy to the items it governs at all?Positional stability and the "most-specific wins" ordering - Both of those problems trace back to the same root, which is that
Neither of these exists if an item names its policy directly (a One reasonable reason to be comfortable with JSONPath here would be that UCP already uses it, in
So the existing JSONPath usage looks like a precedent for a descriptive pointer that attributes a number, not for a governing selector that assigns an eligibility status with override semantics, which is what returns needs. Extension v/s Core"Core, not negotiated. Policies are facts the business states directly; a platform reads them with no capability negotiation. They are first-class response data, present the same way across catalog, cart, and checkout." Could we separate the two claims bundled there, because I think the first is clearly right and the second does not follow from it? The "facts the business states directly" part is right, and nobody would push back on it: policies are read-only, the buyer does not select one, and there is no request-side machinery. But that establishes that policies are response-only and non-interactive, which is a different axis from core vs. extension. An extension can be entirely read-only, response-only, first-class response data too. "It is a stated fact" tells us there is no selection flow; it does not tell us where the shape should live. The clearest evidence is the existing extensions. An applied discount amount is also a fact the business states directly, the buyer does not select it, and it is first-class response data, yet discount is an extension. Fulfillment is an extension. So "business-stated fact" cannot be the thing that makes something core, because our current core-vs-extension line already puts business-stated facts in extensions. The question is not whether policies are facts (they are), but whether the platform benefits from knowing in advance that these facts will be present and in what shape, and there the extension form still wins:
|
|
Good points and questions. Let's try to tease this apart... binding &
|
|
Hi @igrigorik, Great points! So here's a concrete hybrid that keeps your container untouched and closes the gaps still open, both the domain ones and the two technical ones you flagged. The short version: your core container + #257's refund domain model as the negotiated type + a defined precedence rule. The type is advertised, so a platform knows it's coming and can validate it: "capabilities": {
"dev.ucp.shopping.policy.refund": [
{ "version": "2026-04-08",
"extends": ["dev.ucp.shopping.checkout", "dev.ucp.shopping.cart", "dev.ucp.shopping.catalog"],
"spec": "...", "schema": ".../policy_refund.json" }
]
}The wire shape is your container, unchanged. Cart-wide stays one entry with "policies": [
{ "type": "dev.ucp.shopping.policy.refund",
"description": { "plain": "Free 30-day returns from delivery." },
"window": { "days": 30, "from": "delivered" },
"supported_resolutions": ["original_payment_method", "exchange"],
"methods": [
{ "type": "in_store", "fee": { "type": "free" } },
{ "type": "by_mail", "fee": { "type": "fixed_fee", "amount": 500 } }
] },
{ "type": "dev.ucp.shopping.policy.refund",
"description": { "plain": "Engraved items are final sale." },
"applies_to": ["$.line_items[?(@.id=='engraved_watch')]"],
"final_sale": true }
]Why adopt #257's model for the body rather than keep Worth grounding this in the closest sibling we already have: fulfillment. It's the reverse-logistics counterpart to returns, and two things about it support this shape directly. First, it's a single negotiated extension that already spans catalog and checkout (composing onto So the three pieces map cleanly onto where we landed:
Net: one primitive, one well-known type that actually expresses what buyers decide on, and no undefined precedence. Does this match what you meant by "how we ship that type," and if so, can I propose we continue with the PR#257? I can work with venkatesh-ucp to update the shapes based on this proposal. |
A response can carry several policies of the same `type` — a store-wide default plus per-item overrides. A reader needs one deterministic answer to "which policy governs this node?", or the same document renders differently across platforms. The initial cut left that as a one-line "most-specific match wins" and embedded a refund sketch in the type. This commit makes the resolution rule precise and pulls the refund domain body out to its own proposal, so the generic container can stand on its own as infrastructure. `applies_to` binds a policy to nodes by RFC 9535 JSONPath, relative to the response root — the convention `messages[].path` and `discount.allocations[].path` already use. RFC 9535 also gives us the two instruments precedence needs, so we don't build a parallel system: - a Normalized Path (Section 2.7) is a node's canonical identity: `$.products[0].variants[3]` is the segment sequence `products, 0, variants, 3`, and the root `$` is the empty sequence; - a singular query (Section 2.3.5.1) is an expression that names exactly one node using only name and index selectors, as opposed to a filter, wildcard, or slice, which match a set. A direct id-reference would need neither, but it also can't express the thing policy resolution is mostly about: cascade. A target covers the node it names and everything nested under it, so `$.products[0]` governs a product and all its variants in a single entry, while `$.products[0].variants[3]` overrides just that variant. An id names one item; the cascade would have to be enumerated node by node. For a given `type`, among the policies whose target covers a node: 1. A target covers a node when the node it matches is that node or an ancestor of it — equivalently, when the matched node's Normalized Path is a prefix of the target's. That prefix length is its depth (a Response-wide policy targets the root `$`, so depth 0). 2. Score each policy by its deepest covering match as `(depth, precision)`: depth first; precision is 1 for a singular query and 0 for a Set match. 3. The greatest score governs — deepest wins, and at equal depth a singular query outranks a Set match. So a variant-level target (depth 4) beats a product-level one (depth 2) beats a response-wide default (depth 0); and where two reach the same node at the same depth, `$.products[0]` (singular) beats `$.products[?@.category=='electronics']` (a set). Depth first is deliberate: ranking selector kinds regardless of nesting would let a wildcard on a variant lose to an index on the product, inverting the cascade. And we stop at the singular-vs-set distinction because that is the only ordering RFC 9535 defines. When two same-`type` policies cover a node at the same depth and precision — two overlapping Set matches, or two Response-wide entries — the outcome is undefined. This is model-independent: naming the node by an id would collide the same way, because the author has said two contradictory things about one node. A Business MUST NOT publish such a collision; a Platform MUST surface a warning rather than resolve it silently.
|
@amithanda ty good feedback (again) and indeed, I think we're close to the finish line on the underlying primitive. Sequencing: let's separate refund policy definition from the underlying primitive. Specifically, I propose we use this PR to land the infrastructure, and then rebase #257 to finalize the shape for the specific refund policy. To that end, I gutted the refund policy object in this PR: it's an empty placeholder that #257 can define. Precedence: yes good point, we need to spell out how this works; "most specific wins" is ambiguous. To that end, ptal at bca2577 — the commit message goes in depth to explain the reasoning behind the updated shape, and leans into primitives defined by JSONPath spec. The resolution algorithm is also defined, and I vetted it by running it through a series of agents that were asked to implement matching algorithm in various languages -- all passed. |
Context: #257. This is an alternate design that tackles generalized use case of policies with refund as one possible types, and applies across catalog, cart, and checkout.
policies[]is a new core response field: a small, generic primitive for business to state rules that apply to what's being sold — refund windows, final sale, warranty, or anything custom — right on catalog, cart, and checkout responses. It augmentslinks[]and integrates withmessages[]rather than inventing new machinery.What it looks like
A cart/checkout-wide refund policy sits in the response alongside the items it governs:
{ "line_items": [ ... ], "policies": [ { "type": "dev.ucp.shopping.policy.refund", "description": { "plain": "Free 30-day returns from delivery." }, "window": { "days": 30, "from": "delivered" } } ] }Targeting a single item — and the disclosure that must be shown for it. The policy states the fact (
final_saleon the engraved line item; most-specificapplies_towins), and themessages[]warning compels its display, linked back to the policy bycode == typeat the samepath:{ "line_items": [ ... ], "policies": [ { "type": "dev.ucp.shopping.policy.refund", "description": { "plain": "Free 30-day returns from delivery." }, "window": { "days": 30, "from": "delivered" } }, { "type": "dev.ucp.shopping.policy.refund", "description": { "plain": "Engraved items are final sale." }, "applies_to": ["$.line_items[2]"], "final_sale": true } ], "messages": [ { "type": "warning", "code": "dev.ucp.shopping.policy.refund", "path": "$.line_items[2]", "presentation": "disclosure", "content": "Engraved items are final sale and cannot be returned." } ] }The same primitive on a catalog response, carrying a custom policy a platform may not model — still readable from
description:{ "products": [ ... ], "policies": [ { "type": "com.example.policy.price_match", "description": { "plain": "We match competitor prices for 14 days." }, "applies_to": ["$.products[0]"] } ] }Scope of this draft
The focus here is the macro shape: the policy envelope, how entries target items, how they augment
links[], and how disclosure flows throughmessages[]. Specific policy types then layer their own structured fields on top — and therefundfields shown above (window,final_sale) are an initial sketch of what that can look like, not a finalized set.Design choices
typefrom an open reverse-DNS vocabulary (dev.ucp.shopping.policy.*for well-known, your own domain for custom).refundis simply the first type with structured fields (windowxorfinal_sale);warrantyand custom types ride the same shape. Platforms tolerate types they don't know — no closed enum, no central registry, no namespace collisions.links[].links[]stays the always-present, response-wide fallback (a labeled URL).policies[]is the structured layer on top; a policy that omitsurlresolves thelinks[]entry of the corresponding type.messages[]. Policies never force display. When something must be shown, the business emits apresentation: "disclosure"warning — the one existing forcing channel, already used for notices like Prop 65 — and links it to the policy viacode == type. No second display system.applies_tois an RFC 9535 JSONPath array relative to the response root; omitted means the whole response, and the most-specific match wins.typeand adescription, so it is presentable even by a platform that does not model the type.!for breaking changes).