chore: backport loyalty extension to 2026-04-08 release - #813
Merged
damaz91 merged 2 commits intoSep 9, 2026
Merged
Conversation
damaz91
requested review from
mmohades,
tormin-google,
wry-ry and
yanheChen
and removed request for
a team
September 8, 2026 19:47
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
…al-Commerce-Protocol#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 Universal-Commerce-Protocol#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
damaz91
force-pushed
the
chore/backport-loyalty
branch
from
September 8, 2026 19:53
58e377e to
133b31f
Compare
damaz91
requested review from
amithanda and
jingyli
and removed request for
a team,
mmohades,
tormin-google,
wry-ry and
yanheChen
September 8, 2026 20:11
amithanda
approved these changes
Sep 9, 2026
jingyli
approved these changes
Sep 9, 2026
damaz91
merged commit Sep 9, 2026
a25a4a2
into
Universal-Commerce-Protocol:release/2026-04-08
21 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Backports the loyalty extension (#340 / commit
240097bcbc31ab3c19b0bb8fad03a07379eea33d) to therelease/2026-04-08branch.Adjustments for 2026-04-08 Release:
$refinsource/schemas/common/loyalty.jsonfromtypes/reverse_domain_name.jsonto../shopping/types/reverse_domain_name.jsonto match the type location in the 2026-04-08 release.source/schemas/commontoSCHEMAS_DIRSinmain.pyso MkDocs macros (extension_schema_fields) can resolve schemas undercommon/.ucp-schemacaching in.github/actions/setup-build-env/action.ymlusingcargo info(matching ci: fix ucp-schema caching #522).js-yamlandfast-uriinpackage.json/package-lock.jsonto resolve Super-linter CVE-2026-53550.Category (Required)
ucp-schematool (resolver, linter, validator). (Requires Maintainer approval)Related Issues
Backports #340
Checklist
!for breaking changes).Screenshots / Logs (if applicable)
./scripts/build_local.sh.check_links.py.ucp-schema lintanducp-schema resolve.Docs Build and Deploy,Lint Code Base,Conventional Commits,PR Governance,cla/google).