Skip to content

feat: add policies[] to cart, checkout, and catalog#572

Open
igrigorik wants to merge 6 commits into
mainfrom
feat/policies
Open

feat: add policies[] to cart, checkout, and catalog#572
igrigorik wants to merge 6 commits into
mainfrom
feat/policies

Conversation

@igrigorik

@igrigorik igrigorik commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

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 augments links[] and integrates with messages[] 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_sale on the engraved line item; most-specific applies_to wins), and the messages[] warning compels its display, linked back to the policy by code == type at the same path:

{
  "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 through messages[]. Specific policy types then layer their own structured fields on top — and the refund fields shown above (window, final_sale) are an initial sketch of what that can look like, not a finalized set.

Design choices

  • 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.
  • A generic primitive, not a refund feature. The base envelope validates any type from an open reverse-DNS vocabulary (dev.ucp.shopping.policy.* for well-known, your own domain for custom). refund is simply the first type with structured fields (window xor final_sale); warranty and custom types ride the same shape. Platforms tolerate types they don't know — no closed enum, no central registry, no namespace collisions.
  • Augments links[]. links[] stays the always-present, response-wide fallback (a labeled URL). policies[] is the structured layer on top; a policy that omits url resolves the links[] entry of the corresponding type.
  • Integrates with messages[]. Policies never force display. When something must be shown, the business emits a presentation: "disclosure" warning — the one existing forcing channel, already used for notices like Prop 65 — and links it to the policy via code == type. No second display system.
  • Targeting. applies_to is an RFC 9535 JSONPath array relative to the response root; omitted means the whole response, and the most-specific match wins.
  • Required floor. Every policy carries a type and a description, so it is presentable even by a platform that does not model the type.

  • Core Protocol: Changes to the base communication layer, global context, or breaking refactors.
  • Capability: New schemas (Discovery, Cart, etc.) or extensions.
  • I have followed the Contributing Guide (including Conventional Commits title requirements and ! for breaking changes).
  • I have updated the documentation (if applicable).
  • My changes pass all local linting and formatting checks.

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.
@igrigorik
igrigorik requested a review from amithanda July 8, 2026 04:36
@igrigorik igrigorik self-assigned this Jul 8, 2026
@igrigorik igrigorik added the TC review Ready for TC review label Jul 8, 2026
@yairsabag

Copy link
Copy Markdown

While working through the policies[] design, I tried to express a class of merchant policies that is standard in services and bookings commerce, and I don't think the current shape can represent it. Sharing the cases first, since they define the requirement better than any schema:

  • Hotels: the same room is free-to-cancel until 30 days before check-in for an August stay, but only until 48 hours for a November stay. The policy is a property of the booking, anchored to the check-in date.
  • Events: a ticket refunds 100% until 7 days before the event, 70% until 72 hours, 30% until 24 hours, 0% after. Early-Bird, VIP and Last-Minute tiers of the same event each carry different schedules.
  • Deposits and services: a venue or contractor deposit becomes non-refundable in stages as the date approaches.

Three properties recur across all of these:

  1. The buyer's entitlement is time-tiered — it steps down at known deadlines rather than flipping once.
  2. Deadlines are relative to an anchor (check-in, event start), not to purchase or delivery.
  3. Outcomes are partial (70%, 30%), not binary refundable / final-sale.

The proposed window: { days, from } idiom expresses a single binary window measured from a fulfillment-lifecycle reference point, so as far as I can tell none of the three properties fit. Stacking multiple policy entries doesn't quite work either, because the tiers form one policy with ordering and mutual-exclusivity semantics, not independent facts.

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 links[], integrating with messages[], no new machinery):

{
  "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
}

(until counted backwards from anchor; tiers strictly ascending in proximity, strictly descending in buyer_bps; purely descriptive — execution stays wherever it lives today.)

Two questions for the authors:

  1. Is the intent that such policies be forced into window, expressed as multiple entries, or would a tiered construct within a policy type be in scope for this design?
  2. If in scope — should the anchor concept live at the policy level (as above), or be shared with checkout-level fulfillment data?

Happy to draft the schema addition and examples if a tiered construct is welcome.

@igrigorik

Copy link
Copy Markdown
Contributor Author

@yairsabag this is a great stress test and case study. UCP's key role here is to define a common primitive (policies[]) plus a small set of shared base domain models; the whole point of this "small core" split is that more specialized strategies can be built on top without touching core.

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 applies_to targeting, messages[] disclosure, and the links[] fallback — see below.

Q1: a tiered construct is in scope, and it doesn't need to be forced into window or split across entries. window + final_sale is a sketch of what we could define in base. As a rule of thumb, if we can chisel out a shape that satisfies ~80%+ of related workflows, its worth considering for core, but it does not mean that it must be comprehensive and solve every permutation, that's what extensions are for.


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 policies[] wherever it appears — cart, checkout, and catalog — via if type == "com.example.policy.cancellation" then <body>, using the same composition pattern discount and fulfillment already use to extend those surfaces.

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 tiers; one that didn't can still render the policy if it wants to via description and optional messages[] disclosure.

This is the division of labor we want: core stays small — the policies[] primitive and the base domain models everyone shares — and specialized or vertical-specific strategies like this one are extensions. You can define and ship com.example.policy.cancellation today without waiting on a core change. If a shape turns out to be broadly shared, it's a candidate to be standardized as a shared extension, so platforms build one renderer for it — but that's a separate conversation, and either way it's an extension over the primitive, not a new core field.

Q2: for a self-contained extension type I'd keep anchor on the policy — it's intrinsic to what the policy means (the booking's check-in / event start) and keeps the entry readable without joining against anything.

@yairsabag

Copy link
Copy Markdown

Thank you, this is exactly the guidance I needed, and the composition
sketch + gist make the path concrete. Agreed on keeping anchor on the
policy. I'll prototype the extension following this pattern and validate
it against real services/bookings scenarios to see whether the shape
holds up in practice; if it does, I'll bring back implementation
experience and usage examples to inform the shared-extension
conversation. thanks again.

@yairsabag

Copy link
Copy Markdown

Quick follow-up: I validated the sketched shape against ~40 real cancellation
policies across hospitality, ticketing, services and Israeli consumer-law
sources. The anchor+tiers core held up well. The corpus exposed two recurring
generalizations: (1) outcomes are not always percentages — flat fees and unit
deductions ("one night", "one session") are equally standard, which points to
a small discriminated outcome union; and (2) cancellation-adjacent outcomes
are triggered by more than buyer cancellation — no-show and seller
cancel/postpone carry distinct outcomes in every domain, ticketing especially.

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:
https://github.com/yairsabag/policy-cancellation

Two things deliberately kept out: composite outcomes (the Israeli statutory
"lesser of 5% or ₪100" — real, but a combinator), and
discretionary/history-dependent policies (~30% of the corpus has such zones;
documented as a machine-readability boundary rather than schema surface).

@amithanda

Copy link
Copy Markdown
Contributor

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 applies_to makes the policy-to-item relationship a query to be evaluated rather than a reference to be resolved, and a query brings brittleness and ambiguity that a direct reference does not:

  • Brittleness: a positional path ($.line_items[2]) is only correct for one exact arrangement of the array, so any insertion, removal, or re-sort silently retargets the policy. A filter path ($.line_items[?(@.id==...)]) avoids that but shifts the burden to the producer to author correct expressions and depends on the target carrying a queryable key.
  • Ambiguity: once two policies of the same type can both match an item, the platform needs a precedence rule to pick a winner, and RFC 9535 does not define one, so "most-specific wins" is left to each implementation.

Neither of these exists if an item names its policy directly (a return_policy_id / policy_ref resolving to a keyed entry): the binding travels with the item, and each item names at most one policy, so there is nothing to re-evaluate and nothing to rank.

One reasonable reason to be comfortable with JSONPath here would be that UCP already uses it, in discount.json (allocation.path, e.g. $.line_items[0]) and in the messages[] types. That is true, but returns is a different domain, and the same syntax carries a heavier load here:

  1. In discount, the path is attribution; here it is assignment. A discount allocation describes where a computed amount landed, and it is supplementary: the authoritative facts are applied_discount.amount and title. In policies[], applies_to is the only thing binding a policy to the item it governs, so it is load-bearing rather than descriptive.

  2. Discount allocations are additive; policies are exclusive. Allocations never compete, they sum to the discount total, so discount never needs a "which one wins" rule. A refund policy is an override relationship (a per-item final-sale beats a response-wide window), which is exactly the precedence contest JSONPath does not resolve for us.

  3. A wrong path degrades very differently. If a discount path is off, the attribution is wrong but amount and the receipt total are still authoritative, so the buyer is charged correctly. If a policies[].applies_to is off or drifts, a final-sale item is advertised as returnable, or the reverse, which is a consumer-facing misstatement of a legal term rather than a reconciliation glitch.

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:

  1. Negotiation is not a gate on reading facts; it is a declaration that the facts exist. As an extension, dev.ucp.shopping.return in the profile tells a platform, before any response, that this merchant discloses returns and how to validate them, exactly as discount is declared today. As an optional core field, an absent or empty policies[] is ambiguous: no policy, a producer that does not populate it, or an older producer. "No negotiation" does not make the data more available; it removes the signal that it is coming.

  2. "Reads them with no negotiation" does not actually save the platform work; it relocates it. Without a declared contract, a platform still has to defensively handle absence and unknown type values at read time, so the work moves from a one-time discovery check to per-response defensive parsing. Negotiation is a step the platform already performs during discovery; in exchange it gets a composed schema that validates the fields (a method's required type/fee, amount for a fixed fee) rather than an additionalProperties: true envelope with typed bodies gated on an open string.

  3. "Present the same way across catalog, cart, and checkout" is achievable as an extension. Uniform presence across surfaces is not exclusive to core. An extension can declare multiple parents, the way discount extends cart and checkout; a returns/policy extension can extend catalog, cart, and checkout and appear identically on all three. So we can keep the cross-surface uniformity the design wants without putting the shape in non-negotiated core.

  4. Blast radius. Because the refund body is still evolving (the PR calls it an initial sketch, and fee model, resolutions, and duration-vs-deadline are all live), every refinement as a core field is a core version event across three surfaces, whereas as an extension it is a single capability bump.

@igrigorik

Copy link
Copy Markdown
Contributor Author

Good points and questions. Let's try to tease this apart...

binding & applies_to

  • It's an existing contract, not a new one. applies_to reuses the JSONPath pointer already load-bearing in UCP — discount.allocations[].path and messages[].path on error/warning/info, all documented as "RFC 9535 JSONPath to the component the message refers to (e.g. $.line_items[0])." And that contract already carries legal-weight content: a message_warning with presentation: "in" is a MUST-display / MUST-NOT-dismiss notice bound to its item by the same positional path. A drifted path there misstates a legal notice against the wrong item — the exact failure mode being highlighted. So policies aren't a new stakes class, and I don't think attribution-vs-assignment singles them out.

  • Positional isn't an issue within a response; id is optional, not mandated. A response is an immutable document and JSON guarantees array order, so an index resolves unambiguously against it — same as every existing path. Filter-by-id ($.line_items[?(@.id=='li_1')]) is a fine and allowed option if a producer prefers it, but requiring it would make policies inconsistent with the rest of the spec.

  • Not repeating a shared policy on every line IS the point. A cart-wide policy is one entry with applies_to omitted; a targeted one is one entry pointing at its items. That's the whole reason the pointer contract exists (cart-wide discounts, messages) — and returns are usually cart-wide, which is exactly the case an item→policy reference handles worst, stapling the same policy onto every line. This is the exact same reason we zero'ed in on applies_to for discount and messages as well — we don't inline those onto every object; policies[] should not be any different.

  • Specificity is an authoring concern everywhere, not a targeting flaw. Nothing stops a business from authoring contradictory policies under any scheme — per-item references included; an item can name two conflicting policies just as easily. The protocol can't prevent bad authoring. What it can do is define precedence, and I think it should: most-specific target wins (a per-item override beats a cart-wide default). Equal-specificity, same-type collisions stay a SHOULD-NOT, like any overlapping annotation.

core vs. extension

  • policies[] is a container primitive — same class as messages[], links[], signals. You're right that "the business states it directly" isn't what makes something core. The better explainer reason: policies[] defines the base contract for a policy — a target + a description — that an agent can act on without knowing the specific type. An agent that's never heard of a hazmat policy can still surface it from type + description, exactly how an unknown links[] type degrades today, and how prop-65-style disclosures already work. That graceful floor is the entire value of a core container, and it's independent of whether any given type is negotiated. Further, it creates a coherent and consistent container for all the policies — we can assert that a platform that speaks version > x can present all policies because it conforms to base contract.

  • Which types UCP defines, and where they live, is the separate (and real) conversation. Should dev.ucp.shopping.policy.refund exist? I think yes — our objective is to define the ~80% shape (window, final-sale, the common cases) and let people extend for the long tail (tiered cancellation, price-match, etc). Your other three points — presence signalling, strong composed validation, blast radius — all correctly attach to the typed model, not the container, and they're exactly the right inputs to how we ship that type. Y

@amithanda

amithanda commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Hi @igrigorik,

Great points!
Agreed on the container-vs-type split, that resolves most of it: policies[] as a core container (peer to messages[] / links[] / signals, degrading from type + description) is right, and the messages[] precedent means this isn't a new stakes class.

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 applies_to omitted; the override targets by id, not index. The body carries #257's depth:

"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 window xor final_sale: the binary sketch can't structurally express the cases that most affect a purchase decision. "30-day, money back," "30-day, store-credit only," and "30-day, exchange only" all collapse to the same window { 30 }, and return cost / channel have no fields at all, so they degrade to free text an agent can't compare. #257's supported_resolutions + methods/fees already solve exactly this, and folding them in is the ~80% shape you want for a well-known type.

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 catalog.search, catalog.lookup, get_product, and checkout), so a cross-surface refund type needs no core field to be present uniformly, it just declares its parents. Second, fulfillment is the one extension that attaches domain data to specific line items, and it binds by line_item_ids reference, not JSONPath (only discount.allocations[].path and messages[].path use JSONPath, both as pointers). So targeting the refund type by id rather than positional path isn't a new pattern, it matches how the nearest sibling already binds, and it's also why fulfillment never needs a precedence rule: each item resolves to one method by construction.

So the three pieces map cleanly onto where we landed:

  • Container core, ergonomics preserved. Your shape. Cart-wide is one omitted-applies_to entry; an unknown type still renders from type + description. Nothing about the primitive changes.
  • feat: add Return Extension to UCP specification #257's domain depth becomes the typed model. This is where presence-signalling and composed validation "attach to the type, not the container," as you put it, and it closes the refund-destination, exchange-only, and cost/channel gaps in one move. The type versions on its own cadence while it settles, instead of churning core.
  • Precedence is defined, not left to implementations. Keep "most-specific wins," pinned to a computable ladder (single index/key > slice > filter > wildcard > omitted) with a deterministic tie-break, and target by id so the pointer is stable. That removes the one ambiguity without changing the mechanism.

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.
@igrigorik

Copy link
Copy Markdown
Contributor Author

@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.

@igrigorik
igrigorik marked this pull request as ready for review July 14, 2026 05:18
@igrigorik
igrigorik requested review from a team as code owners July 14, 2026 05:18
@damaz91
damaz91 removed request for a team, alexpark20 and knightlin-shopify July 14, 2026 08:00
@damaz91 damaz91 added gov:needs-gc-review status:needs-triage Signal that the PR is ready for human triage and removed status:needs-triage Signal that the PR is ready for human triage labels Jul 14, 2026
@igrigorik igrigorik added this to the Working Draft milestone Jul 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants