Skip to content

Commit 240097b

Browse files
ziwuzhou-googleigrigorikamithanda
authored
feat: loyalty extension for catalog/cart/checkout capability (#340)
* Update documentation based on review comments. * Remove balance related defintions per TC alignment. * Move the amount type out to common folder to reuse in loyalty extension. * Make loyalty extension response only. * collapse tracks→tiers; multi-claim discount eligibility Counter-proposal to the loyalty extension shape currently in #340. Three connected changes rebalance where multiplicity lives in the schema, so each concern is modeled in exactly one place at the right level of abstraction. 1. loyalty_membership.tracks[] → loyalty_membership.tiers[] (always-array); membership_track.activated_tier pointer dropped. 2. discounts.applied[].eligibility lifted from `string` to `string | string[]` so compound (multi-claim) discounts attribute honestly to every claim that contributed. 3. earning_breakdown.source (JSONPath) → earning_breakdown.benefit_id (id ref). 4. loyalty_membership.provisional becomes required (no default) so every entry consciously declares its verification state — no aspirational "here's a program you don't hold" entries leak through. 5. membership_track def retained (defined-but-unreferenced at runtime) as a primitive for future discovery surfaces; runtime and a future discovery shape compose from the same primitives (membership_tier, membership_tier_benefit, reward_currency) so neither has to fork. The original shape used `tracks[].tiers[]` with an `activated_tier` pointer back into the array. This conflated three distinct concerns: - Multi-program enrollment (Amazon Prime + Prime Visa) - Compound benefits unlocked across programs - Multi-status within one program (Hilton current + lifetime, United Premier + Million Miler) The first two are better solved at higher layers: - Multi-program enrollment → separate sibling top-level keys in the loyalty map, namespaced by reverse-DNS (com.amazon.prime, com.amazon.prime.visa). Each program is its own eligibility claim, independently verifiable, and symmetric with `context.eligibility[]` on the request side. - Compound benefits → see next section. Only multi-status remains as a structural in-membership concern, and tier names in real programs already carry the disambiguation ("Lifetime Diamond", "Million Miler") — no dimension wrapper is needed. So `tracks[]` collapses to `tiers[]`: // Single-status (most programs) "com.sephora.beauty_insider": { "id": "BI_12345", "name": "Beauty Insider", "tiers": [{ "id": "vib_gold", "name": "VIB Gold" }], "provisional": false } // Multi-status (Hilton) "com.hilton.honors": { "id": "HH_67890", "name": "Hilton Honors", "tiers": [ { "id": "gold", "name": "Gold" }, { "id": "lifetime_diamond", "name": "Lifetime Diamond" } ], "provisional": false } `tiers[]` always-array matches UCP convention (line_items[], discounts.applied[], messages[] are all plural even when typically length 1). Consumers handle one shape; multi-status programs surface multiple tiers without a polymorphic field or per-consumer Array.isArray check. Some loyalty benefits are multiplicative — they exist only when the buyer holds multiple memberships simultaneously: - Amazon: Prime Visa earns 3% back; with Prime membership the rate becomes 5%. The extra 2% is unlocked by holding BOTH. - United: Premier members + co-branded card holders get 15% off mileage redemptions. Neither alone qualifies. The original shape proposed handling this by nesting both programs as `tracks[]` within a single membership entry under one eligibility key. That hides the attribution: a discount tagged `eligibility: "com.united.mileageplus"` reads to the agent as "you get this because you're a MileagePlus member" — true but incomplete. The agent loses the ability to faithfully render "you get this because you're a MileagePlus member AND a cardholder," and the structural conjunction exists nowhere on the wire. Lifting `discounts.applied[].eligibility` to `string | string[]` makes multiplicative benefits first-class: "loyalty": { "com.amazon.prime": { "id": "AMZ_001", "tiers": [{ "id": "member" }], "provisional": false }, "com.amazon.prime.visa": { "id": "VISA_007", "display_id": "****1234", "tiers": [{ "id": "cardholder" }], "provisional": false } }, "discounts": { "applied": [ { "title": "Prime + Visa Bonus 2%", "amount": 200, "automatic": true, "eligibility": ["com.amazon.prime", "com.amazon.prime.visa"], "allocations": [{ "path": "$.line_items[0]", "amount": 200 }] } ] } Semantics are conjunctive: ALL listed claims were required for the discount to apply. Disjunctive (any-of) eligibility — where any one of several claims independently unlocks the same benefit — should continue to be modeled as separate `applied` entries, one per claim that satisfies the rule. This also resolves a structural asymmetry: each top-level loyalty map key represents one accepted eligibility claim, symmetric with the entries in `context.eligibility[]`. The original `tracks[]` introduced a second layer of plurality that wasn't addressable from `context.eligibility` and couldn't surface in a discount's attribution. Sibling keys + array eligibility keep both ends of the protocol symmetric. `earning_breakdown.source` was a JSONPath pointing at the benefit that produced a rewards rule. UCP convention (discounts.allocations[].path, messages[].path) is that JSONPaths are document-root-relative. The original example paths (`"$.tiers[0].benefits[1]"`) were implicitly scoped to the parent membership, breaking that convention. Making the path root-relative produces verbose, fragile references that bake the eligibility map key into the path: "$.loyalty['com.example.loyalty'].tiers[0].benefits[1]" Every breakdown entry repeats the key it lives under. Rename the map key — every internal path breaks. `benefit_id` references the benefit by its stable `id`, scoped naturally to the parent loyalty membership: "breakdown": [ { "id": "br_1", "amount": 30, "description": "1 point per $1", "benefit_id": "BEN_002" }, { "id": "br_2", "amount": 20, "description": "2x footwear bonus", "benefit_id": "BEN_003" } ] The display-ready `description` continues to carry the human-readable rationale (no dereference required for rendering); `benefit_id` is purely for programmatic correlation if an agent wants to highlight the source benefit. Same information surface, simpler shape, no path-resolution fragility. - All primitive defs (membership_tier, membership_tier_benefit, reward_currency, earning_forecast, earning_breakdown, membership_reward, membership_track) — only their relationships and multiplicities change. - Earning forecast feature surface is unchanged; it lives one level higher (loyalty_membership.rewards[] instead of loyalty_membership.tracks[].rewards[]) as a natural consequence of removing the track wrapper. - Reverse-domain naming convention for loyalty map keys. - provisional/verified state machine (now made required-explicit). * Documentation and schema changes based on latest alignment. * wrap loyalty specification prose Normalize the loyalty document header to match the surrounding spec files and wrap long prose lines so follow-up semantic edits are reviewable. * remove required loyalty provisional field The loyalty response should follow the same exception-flag pattern as applied discounts: verified state is the default, and unverified accepted claims mark the field true. Remove provisional from loyalty_membership.required, add default false, and update the prose so it no longer describes the field as required. The normative behavior for unverified accepted claims remains explicit in the loyalty behavior section. * clarify loyalty price-impact attribution Loyalty should not require the discount extension just to represent a price-impacting benefit. The base capability totals remain the source of truth for the price effect. Document that businesses must surface applied loyalty price impacts through base totals, and should also populate discounts.applied[] when the discount extension is active for structured attribution. Discount eligibility and provisional state are scoped to that optional discounts.applied[] attribution surface, not to the base loyalty response. * Allow provisional loyalty tier context Accepted-but-unverified loyalty claims can still carry display-safe context for the state the business is willing to act on during the session. Requiring tiers[] to be empty made provisional loyalty hard to explain to buyers even when provisional pricing or benefits were being shown. Allow provisional memberships to return display-safe tier context while preserving the existing rule that display_id is only returned after verification. Also switch the affected examples to unbranded store-card terminology so the schema behavior is not tied to a card network. * extend loyalty to support catalog Loyalty benefits affect product discovery as well as cart and checkout: member pricing, gated inventory, and accepted eligibility state can be visible before an item reaches the cart. Add loyalty extension bindings for catalog search and lookup responses, including both lookup response shapes. Update discovery docs, price-impact prose, and spec navigation metadata so the extension is advertised across catalog, cart, and checkout. * fix examples & copy edit pass * use reward-specific amounts for loyalty earnings Loyalty reward earnings are denominated in custom reward currencies such as points, stars, or miles, not ISO 4217 fiat minor units. Reusing the shared monetary amount type made earning forecasts inherit the wrong currency semantics. Add a local reward_amount definition, use it for earning forecast and breakdown amounts, and constrain reward_currency.decimal_places to non-negative values so the minor-unit interpretation is explicit. * clarify loyalty reward forecast semantics Avoid describing loyalty rewards as values the buyer holds, which can imply spendable balance semantics owned by the loyalty extension. This complements the reward_amount change prompted by currency-modeling feedback: loyalty owns reward types and earning forecasts, while redeemable balances remain scoped to payment instruments or a future redemption capability. * clarify loyalty tier context description Align the tiers schema description with provisional examples: provisional memberships may include display-safe tier context, while display_id remains withheld until verification. * fix(loyalty): remove dead membership_track def and fix description quotes The membership_track $def was left unreferenced after the tracks-to-tiers collapse — nothing in the schema points to it. Removing it prevents tooling and doc generators from surfacing a phantom entity. Curly/typographic quotes in description example values replaced with single quotes, consistent with the convention used across all other UCP schemas (e.g. price.json, total.json, fulfillment_option.json). * Fix a few inconsistencies around provisional field. * Enforce `provisional` as required field and clarify behavior for invalid membership - Mark the `provisional` under loyalty membership object as required to make sure platform will never mis-interpret the state of membership. This pattern is asymmetric to `discount` extension as most discount are merchant-set while loyalty memberships are largely tied to platform sent claims. - Clarify the expected behvaior when claimed membership is accepted but failed to be verified. Business shouldn't echo back invalid membership shape and solely relying on the error message is sufficient to explain. * Remove updates to discount extension to make the change non-breaking - Drop the previously introduced expansion of `eligibility` field within `discount` extension, as the `oneOf` update will make this a breaking change for platfomrms. We will use a separate follow-up PR to introduce back that. As a result, compound discount that requires multiple memberships is temporarily unsupported. - A few updates in loyalty.md to reflect this change. In addition, update the example to showcase how loyalty shipping benefit can be supported, as it is a commonly used monetary price impacting loyalty beneft. - A minor fix of the discount.md to recover an incomplete sentence that was dropped in a previous commit (a8b185d). * Address a minor conflict between schema and spec. * Fix the hardcoded ucp version and replace with dynamic generation --------- Co-authored-by: Ilya Grigorik <ilya@grigorik.com> Co-authored-by: amithanda <amhanda@gmail.com>
1 parent 72f5da6 commit 240097b

4 files changed

Lines changed: 950 additions & 0 deletions

File tree

docs/specification/discount.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,3 +605,4 @@ Multiple discounts applied with full allocation breakdown:
605605

606606
With this data, an agent can explain:
607607
> "Your T-Shirt ($60) got $12 off from the 20% summer sale, plus $3 from your
608+
> loyalty reward (split proportionally). Total savings on this item: $15.

0 commit comments

Comments
 (0)