Commit 240097b
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
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
605 | 605 | | |
606 | 606 | | |
607 | 607 | | |
| 608 | + | |
0 commit comments