You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat: add policies[] to cart, checkout, catalog, and order (#572)
* add policies[] to cart, checkout, and catalog
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.
* define policies[] resolution & precedence rules
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.
* bind policy disclosures to the governing policy
The disclosure pairing said a disclosure pairs with "the policy" at its
`path` node, joined by `code == type`. With precedence now defined,
that's ambiguous when several policies of the same `type` cover the
node. Tie the pairing to precedence instead: a disclosure pairs with the
policy that governs its node — the one precedence selects — which is at
most one, so the pairing is unambiguous.
Naming the governing policy also makes two authoring drifts nameable,
since `messages[]` and `policies[]` are authored separately:
- Contradiction — the notice disagrees with its policy. They are two
statements about one node, so a disclosure's content MUST agree with
the policy it pairs with.
- Dangling — `code` names a `type` no policy covers at the node, so the
pairing resolves to nothing. The notice still displays, so this is a
SHOULD: requiring a backing policy would couple the two arrays and
forbid force-displaying a notice without a structured policy.
Neither carries a Platform MUST-warn. That stays reserved for precedence
collisions — the case that is both machine-detectable and leaves
resolution undefined. A contradiction is free text on the message side,
and a dangling pairing is benign.
* feat: add policies[] to order, decouple refund
- Add `policies[]` to `order` as a read-only snapshot of the policies
that applied at checkout (`ucp_request: omit`), mirroring how
`order.attribution` snapshots checkout data. Completes the catalog ->
cart -> checkout -> order lifecycle; returns are post-purchase, so the
order is the durable home for their terms.
- Remove the refund `allOf` overlay from `policy.json` and delete the
now-empty `policy_refund.json`. Core stays a pure generic container;
the refund type attaches its body via extension composition (#257).
- Type `policy.type` via `$ref` to `reverse_domain_name.json`, so the
reverse-DNS format is enforced rather than described only in prose.
- State in Precedence that a Platform MUST NOT merge same-type policy
bodies: a consumer cannot infer combine-vs-replace semantics, so it
resolves a single governing policy by structure alone. Composition is
authored into the most-specific policy, not resolved at read time.
- Collapse the disclosure-pairing passage to one paragraph plus two
numbered rules (editorial).
* docs: rename refund policy type to return, soften collisions
- Rename the well-known type `dev.ucp.shopping.policy.refund` to
`.return`. schema.org models this as MerchantReturnPolicy (return is
the head concept; refundType is one outcome facet), so `refund` named
the policy after a facet rather than the container. The refund /
exchange / credit outcomes live in the type body defined by #257.
- Soften the collision rule: a Platform SHOULD (was MUST) flag an
authoring collision rather than resolve it silently, treatment left
to the Platform. A MUST-surface obligation was inconsistent with
policy presentation being optional and had no defined audience. The
Business MUST NOT publish a collision is unchanged.
"description": "Policies (e.g., return/refund terms) that apply to the items in this cart. `applies_to` targets are relative to the response root; when absent or empty, refer to the URLs in `links[]`.",
Copy file name to clipboardExpand all lines: source/schemas/shopping/catalog_lookup.json
+14Lines changed: 14 additions & 0 deletions
Original file line number
Diff line number
Diff line change
@@ -86,6 +86,13 @@
86
86
"$ref": "../common/types/message.json"
87
87
},
88
88
"description": "Errors, warnings, or informational messages about the requested items."
89
+
},
90
+
"policies": {
91
+
"type": "array",
92
+
"items": {
93
+
"$ref": "types/policy.json"
94
+
},
95
+
"description": "Policies (e.g., return/refund terms) that apply to the products in this response. `applies_to` targets are relative to the response root; when absent or empty, refer to the URLs in `links[]`."
89
96
}
90
97
}
91
98
},
@@ -180,6 +187,13 @@
180
187
"$ref": "../common/types/message.json"
181
188
},
182
189
"description": "Warnings or informational messages about the product (e.g., price recently changed, limited availability)."
190
+
},
191
+
"policies": {
192
+
"type": "array",
193
+
"items": {
194
+
"$ref": "types/policy.json"
195
+
},
196
+
"description": "Policies (e.g., return/refund terms) that apply to this product. `applies_to` targets are relative to the response root; when absent or empty, refer to the URLs in `links[]`."
Copy file name to clipboardExpand all lines: source/schemas/shopping/catalog_search.json
+7Lines changed: 7 additions & 0 deletions
Original file line number
Diff line number
Diff line change
@@ -61,6 +61,13 @@
61
61
"$ref": "../common/types/message.json"
62
62
},
63
63
"description": "Errors, warnings, or informational messages about the search results."
64
+
},
65
+
"policies": {
66
+
"type": "array",
67
+
"items": {
68
+
"$ref": "types/policy.json"
69
+
},
70
+
"description": "Policies (e.g., return/refund terms) that apply to the products in these search results. `applies_to` targets are relative to the response root; when absent or empty, refer to the URLs in `links[]`."
Copy file name to clipboardExpand all lines: source/schemas/shopping/checkout.json
+8Lines changed: 8 additions & 0 deletions
Original file line number
Diff line number
Diff line change
@@ -106,6 +106,14 @@
106
106
"description": "Links to be displayed by the platform (Privacy Policy, TOS). Mandatory for legal compliance.",
107
107
"ucp_request": "omit"
108
108
},
109
+
"policies": {
110
+
"type": "array",
111
+
"items": {
112
+
"$ref": "types/policy.json"
113
+
},
114
+
"description": "Policies (e.g., return/refund terms) that apply to the items in this checkout. `applies_to` targets are relative to the response root; when absent or empty, refer to the URLs in `links[]`.",
Copy file name to clipboardExpand all lines: source/schemas/shopping/order.json
+8Lines changed: 8 additions & 0 deletions
Original file line number
Diff line number
Diff line change
@@ -96,6 +96,14 @@
96
96
"$ref": "types/totals.json",
97
97
"description": "Different totals for the order."
98
98
},
99
+
"policies": {
100
+
"type": "array",
101
+
"items": {
102
+
"$ref": "types/policy.json"
103
+
},
104
+
"description": "Snapshot of the policies that applied to the items at checkout, captured on the order as a durable record. `applies_to` targets are relative to the response root.",
0 commit comments