Skip to content

feat: loyalty extension for checkout capability#340

Open
ziwuzhou-google wants to merge 12 commits intoUniversal-Commerce-Protocol:mainfrom
ziwuzhou-google:feat/loyalty-v2
Open

feat: loyalty extension for checkout capability#340
ziwuzhou-google wants to merge 12 commits intoUniversal-Commerce-Protocol:mainfrom
ziwuzhou-google:feat/loyalty-v2

Conversation

@ziwuzhou-google
Copy link
Copy Markdown

@ziwuzhou-google ziwuzhou-google commented Apr 8, 2026

Summary

A new loyalty extension is proposed to address a critical trust and transparency milestone: ensuring existing loyalty members can seamlessly access their benefits during an agentic checkout experience. By enabling buyers to see their specific tier and eligible rewards before finalizing a purchase, we address a foundational expectation for program members and remove friction from the checkout funnel.

Motivation

Loyalty is a core concept in Commerce, serving as a primary driver for customer retention and long-term business growth. The value of a program is usually realized at the point of sale. UCP in its current format can provide some support for loyalty, mostly in the checkout capability, in the format of auto-applied monetary member-only benefits such as member price discount and/or cheaper and faster fulfillment. However, loyalty is far more than immediate-value monetary benefits. Fungible delayed-value rewards (e.g. points, miles, cashbacks), for example, is another key motivator, and in certain verticals the single most important motivator, in transactional activity. Buyers expect full visibility into their member-exclusive perks before committing to a transaction. Providing this information via an agent-to-business inquiry builds user trust and ensures that the convenience of an AI-driven checkout does not come at the expense of earned member benefits.

Proposal Scope

Different than PR #251 that aims to provide an all-inclusive schema and support a wide range of use cases, this PR focuses specifically on a baseline version, although still general enough to allow future expansion, that can be used to decorate checkout capability to provide extra loyalty information that enhances the experience. More advanced use cases such as loyalty relationship management (e.g. sign-up, tier upgrade/downgrade) and loyalty rewards transfer/redemption are out of the scope of this proposal.

Specifically the following core use cases of benefit recognition for known members are addressed with the proposal:

  • Price-Impacting Benefits: Real-time application of member-only discounts and free shipping offers
  • Non-Price Benefits: Transparent display of rewards earned or rewards applicable to future purchases
  • Status Recognition: Verification and display of the buyers’ specific loyalty tier

Design Details

Core concepts and hierarchy

Four core concepts are introduced in the schema and structured in a hierarchy:

  • Tracks: Distinct enrollment pathways or program categories that a user can join independently or simultaneously
  • Tiers: Specific achievement ranks or status milestones within a track that unlock escalating value as a member progresses through activity or spend
  • Benefits: ongoing perks and privileges granted to a customer based on their current tier or membership status
  • Rewards: the fungible balances and/or stored value available for the customer to redeem on transactions
  {
    "tracks": [
      {
        "tiers": [
          {
            "benefits": [
              {
                ...
              }
            ]
          }
        ],
        "rewards": [
          {
            ...
          }
        ]
      } 
    ]
  }

This hierarchy mirrors the reality of modern commerce, where loyalty is no longer a single ladder but a collection of parallel journeys. By separating the enrollment Track from the achievement Tier, the schema allows a user to hold multiple statuses simultaneously—like being both a credit card holder and a paid subscriber—without creating data "collisions" or redundant, "hard-coded" combined states. This structure ensures the protocol remains normalized and scalable, capable of effortlessly aggregating overlapping benefits across any number of diverse loyalty paths.

Concretely, if the loyalty program uses traditional Earned Loyalty Model (single-track ladder-like system), the hierarchy would normally have one track only with multiple tiers defined within; Conversely, if the loyalty program uses Hybrid Loyalty Model (different "entry points" based on the customer's preference, some with the option to have multiple), the hierarchy would then have more than one track, and each track normally has just one tier.

For a simplest loyalty program/membership (one track, one tier, one benefit), the shape would look like

  {
    "id": "membership_1",
    "name": "My Loyalty Program",
    "tracks": [
      {
        "id": "track_1",
        "name": "track_name_1",
        "tiers": [
          {
            "id": "tier_1",
            "name": "GOLD"
            "benefits": [
              {
                "id": "BEN_001",
                "description": "Complimentary standard shipping on all orders"
              }
            ]
          }
        ],
        "rewards": [
          {
            "currency": {
              "name": "LoyaltyStars",
              "code": "LST"
            },
            "balance": {
              "available": 1000
            }
          }
        ]
      } 
    ]
  }

Eligibility claims

Given almost everything related to loyalty is provisional and requires eligible membership before benefits can be applied, the eligibility/provisional concept introduced in PR #250 naturally fits here where it allows buyers to claim eligibility for loyalty benefits and businesses to verify and communicate the result with the associated effects (financial-wise and rewards-wise). Specifically, the loyalty extension is an object keyed by reverse_domain_name — same convention as services, capabilities, and payment_handlers in the business profile. The key is the eligibility claim, and value is the membership object that contains provisional to indicate the verification result.

"com.example.loyalty": {
    "tracks": [
      {
        "tiers": [
          {
            "benefits": [
              {
                ...
              }
            ]
          }
        ],
        "rewards": [
          {
            ...
          }
        ],
      } 
    ],
    "provisional": false
  }

Example use cases

With the help of the design above, the checkout capability can be further decorated to provide full visibility into buyers’ member-exclusive perks and allows the platform to render the extra information to facilitate the transaction.

Price-Impacting Benefits

Alongside the discount extension, loyalty extension can provide buyer status info to allow the platform to transparently assert that correct and comprehensive member discounts are applied. In the example below, platform not only can explain the source of discounts via discounts.applied.title within discount extension, but also assure buyers that these member specific discounts are recognized because of their verified loyalty status via memberships.tracks.tiers.name within loyalty extension (e.g. “My Loyalty Program Gold and Benefit Visa Card benefit applied.”)

=== "Request"

    {
      "context": {
        "eligibility": ["com.example.loyalty", "com.example.loyalty.credit_card"]
      },
      "line_items": [
        {
          "item": {
            "id": "prod_1",
            "quantity": 1,
            "title": "T-Shirt",
            "price": 1000
          }
        }
      ]
    }

=== "Response"

    {
      "line_items": [
        {
          "id": "li_1",
          "item": {
            "id": "prod_1",
            "quantity": 1,
            "title": "T-Shirt",
            "price": 1000
          },
          "totals": [
            {"type": "subtotal", "amount": 1000},
            {"type": "loyalty_gold_discount", "amount": -30},
            {"type": "loyalty_credit_card_discount", "amount": -50},
            {"type": "total", "amount": 920}
          ]
        }
      ],
      "discounts": {
        "applied": [
          {
            "title": "Loyalty member benefit",
            "amount": 30,
            "method": "each",
            "provisional": false,
            "eligibility": "com.example.loyalty",
            "allocations": [
              {"path": "$.line_items[0]", "amount": 30}
            ]
          },
          {
            "title": "Credit Card Members save 5%",
            "amount": 50,
            "method": "each",
            "provisional": false,
            "eligibility": "com.example.loyalty.credit_card",
            "allocations": [
              {"path": "$.line_items[0]", "amount": 50}
            ]
          }
        ]
      },
      "loyalty": {
        "com.example.loyalty": {
          "id": "membership_1",
          "display_id": "****5678",
          "name": "My Loyalty Program",
          "tracks": [
            {
              "id": "track_1",
              "name": "track_name_1",
              "tiers": [
                {
                  "id": "tier_1",
                  "name": "Gold",
                  "benefits": [
                    { "id": "BEN_001", "description": "Early access to sales" },
                  ]
                }
              ],
              "activated_tier": "tier_1",
            }
          ],
          "provisional": false
        },
        "com.example.loyalty.credit_card": {
          "id": "membership_2",
          "display_id": "****1234",
          "name": "Program Visa Card",
          "tracks": [
            {
              "id": "track_2",
              "name": "track_name_2",
              "tiers": [
                {
                  "id": "tier_2",
                  "name": "Visa Card",
                  "benefits": [
                    { "id": "BEN_001", "description": "Same day delivery" },
                  ]
                }
              ],
              "activated_tier": "tier_2",
            }
          ],
          "provisional": false
        }
      },
      "totals": [
        {"type": "subtotal", "display_text": "Subtotal", "amount": 1000},
        {"type": "items_discount", "display_text": "Loyalty member benefit", "amount": -30},
        {"type": "items_discount", "display_text": "Credit Card Members save 5%", "amount": -50},
        {"type": "total", "display_text": "Estimated Total", "amount": 920}
      ]
    }

Reward Earnings Forecast

In addition to immediate-value benefits like member pricing/shipping, delayed-value collectable reward benefits are another crucial element within the loyalty ecosystem. Displaying earnings forecasts of these rewards before the buyer commits complements and to some extent helps agents handle price objections - rewards earning becomes additional value on top of any pricing discount. In this example, businesses provide the reward earning forecast with a breakdown, giving platforms to explain with full transparency on why the buyer is earning and how the earning is calculated.

=== "Request"

    {
      "context": {
        "eligibility": ["com.example.loyalty"]
      },
      "line_items": [
        {
          "item": {
            "id": "prod_1",
            "quantity": 1,
            "title": "T-Shirt",
            "price": 1000
          }
        }
      ]
    }

=== "Response"

    {
      "line_items": [
        {
          "id": "li_1",
          "item": {
            "id": "prod_1",
            "quantity": 1,
            "title": "T-Shirt",
            "price": 1000
          },
          "totals": [
            {"type": "subtotal", "amount": 1000},
            {"type": "total", "amount": 1000}
          ]
        }
      ],
      "loyalty": {
        "com.example.loyalty": {
          "id": "membership_1",
          "display_id": "****5678",
          "name": "My Loyalty Program",
          "tracks": [
            {
              "id": "track_1",
              "name": "track_name_1",
              "tiers": [
                {
                  "id": "tier_1",
                  "name": "Gold",
                  "benefits": [
                    { "id": "BEN_001", "description": "Early access to sales" },
                    { "id": "BEN_002", "description": "1 point/dollar on everything" },
                    { "id": "BEN_002", "description": "2 extra point/dollar on footwear" },
                  ]
                }
              ],
              "activated_tier": "tier_1",
              "rewards": [
                {
                  "currency": {
                    "name": "LoyaltyStars",
                    "code": "LST"
                  },
                  "earning_forecast": {
                    "amount": 30,
                    "breakdown": [
                      { "source": "$.tiers[0].benefits[1]", "amount": 10 },
                      { "source": "$.tiers[0].benefits[2]", "amount": 20 }
                    ]
                  }
                }
              ]
            }
          ],
          "provisional": false
        }
      },
      "totals": [
        {"type": "subtotal", "display_text": "Subtotal", "amount": 1000},
        {"type": "total", "display_text": "Estimated Total", "amount": 1000}
      ]
    }

Type of change

Please delete options that are not relevant.

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing
    functionality to not work as expected, including removal of schema files
    or fields
    )
  • Documentation update

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

@igrigorik
Copy link
Copy Markdown
Contributor

Great work on this, addresses a lot of the feedback we flagged in the TC review of #251.

Squinting at the new shape, can we simplify further? A few observations...

1/ Do we need tracks at the negotiation/confirmation layer? At checkout/cart time, we need to confirm what's active for the buyer. Tracks describe the program's enrollment structure, which belong in benefits catalog, not in the negotiated outcome.

2/ The loyalty.memberships wrapper is unnecessary? There are no sibling keys alongside memberships. If we switch to a map keyed by eligibility identifier (same reverse_domain_name convention we use for services, capabilities, and payment_handlers), the wrapper dissolves; the keys carry meaning, and the structure self-describes which claims produced which outcomes.

3/ Balance is modeled at the wrong layer? Reward balance is a property of a payment instrument -- structurally identical to a gift card. It becomes available post-authentication and the buyer redeems it as a funding source. Loyalty can own the earning forecast (what you'll gain from this transaction); the payments layer should own the balance (what you can spend). Keeping balance on the instrument avoids dual sources of truth when redemption lands later.

4/ provisional and eligibility granularity is wrong? Currently both sit at the membership level. With the map-keyed approach, eligibility becomes the key itself — no separate field, no echo semantics. Each map entry corresponds to one eligibility claim with its own provisional state, which means independent verification per program works naturally.


Here's a simplified shape that captures the above, with two loyalty programs (one confirmed and one provisional):

Request:

{
  "context": {
    "eligibility": ["com.example.loyalty", "com.other.runner"]
  },
  "line_items": [
    { "item": { "id": "prod_1", "quantity": 1, "title": "Jacket", "price": 5000 } },
    { "item": { "id": "prod_2", "quantity": 1, "title": "Cap", "price": 1500 } }
  ]
}

The request side uses context.eligibility as input mechanism for loyalty claims. Alternatively, if user is authed, then business uses that at negotiation time to lookup and reflect applicable loyalty info.

Response:

{
  "line_items": [
    {
      "id": "li_1",
      "item": { "id": "prod_1", "quantity": 1, "title": "Jacket", "price": 5000 },
      "totals": [
        { "type": "subtotal", "amount": 5000 },
        { "type": "discount", "amount": -500, "display_text": "Member 10% off" },
        { "type": "total", "amount": 4500 }
      ]
    },
    {
      "id": "li_2",
      "item": { "id": "prod_2", "quantity": 1, "title": "Cap", "price": 1500 },
      "totals": [
        { "type": "subtotal", "amount": 1500 },
        { "type": "total", "amount": 1500 }
      ]
    }
  ],
  "discounts": {
    "applied": [
      {
        "title": "Member 10% off",
        "amount": 500,
        "method": "each",
        "provisional": false,
        "eligibility": "com.example.loyalty",
        "allocations": [
          { "path": "$.line_items[0]", "amount": 500 }
        ]
      },
      {
        "title": "Free standard shipping",
        "amount": 599,
        "method": "each",
        "provisional": true,
        "eligibility": "com.other.runner",
        "allocations": []
      }
    ]
  },
  "loyalty": {
    "com.example.loyalty": {
      "title": "Example Rewards",
      "member_id": "M12345",
      "tier": {
        "id": "gold",
        "title": "Gold"
      },
      "benefits": [
        { "id": "BEN_001", "title": "Early access to sales" },
        { "id": "BEN_002", "title": "Priority support" }
      ],
      "earning_forecast": {
        "currency_code": "PTS",
        "amount": 60,
        "allocations": [
          { "path": "$.line_items[0]", "amount": 50, "title": "1 point per $1" },
          { "path": "$.line_items[1]", "amount": 10, "title": "1 point per $1" }
        ]
      },
      "provisional": false
    },
    "com.other.runner": {
      "title": "Runner Program",
      "tier": {
        "id": "standard",
        "title": "Standard"
      },
      "benefits": [
        { "id": "BEN_010", "title": "Free standard shipping" }
      ],
      "provisional": true
    }
  },
  "totals": [
    { "type": "subtotal", "display_text": "Subtotal", "amount": 6500 },
    { "type": "items_discount", "display_text": "Member 10% off", "amount": -500 },
    { "type": "shipping_discount", "display_text": "Free standard shipping", "amount": -599 },
    { "type": "total", "display_text": "Estimated Total", "amount": 5401 }
  ]
}

Key diffs and design decisions in this shape:

  • Map keyed by eligibility identifier. The loyalty extension is an object keyed by reverse_domain_name — same convention as services, capabilities, and payment_handlers in the business profile. The key IS the eligibility claim, which means: (a) correlation with context.eligibility and discounts.applied[].eligibility is by key lookup, not array scanning; (b) no separate eligibility field inside the entry; (c) uniqueness is enforced by shape, not validation rules.

  • Singular tier, not an array. At checkout, the buyer has one active tier per program. This is a negotiated outcome, not a catalog. The tiers[] + activated_tiers[] side-channel pattern in the current PR belongs in a future discovery capability where the full program hierarchy is relevant.

  • Benefits explain / provide context. Monetary effects (member pricing, free shipping) are already fully represented in discounts.applied[] via eligibility. The loyalty extension doesn't duplicate those — it carries the informational benefits. Agent can correlate: discounts.applied[0].eligibility === "com.example.loyalty" tells you which financial effects came from which program.

  • No rewards/balance at this layer. Balance is a property of the payment instrument; the instrument owns the balance and the spending mechanics. Loyalty owns what you'll earn, not what you can spend.

  • Earning forecast reuses the allocations pattern. earning_forecast.allocations[] follows the same { path, amount } shape as discounts.applied[].allocations[].

@ziwuzhou-google
Copy link
Copy Markdown
Author

Thanks a lot for the insightful comments @igrigorik. A few questions that I'd like to hear your thoughts:

  1. Mental model gap: I realized that how we understand "memberships"/"programs" might be different, and that is probably the biggest reason on why we proposal the layering and hierarchy in its current format. This is also related to this discussion thread in the previous PR: feat: Add basic schema for loyalty extension #251 (comment). Taking Target as a concrete example. As pointed out in that discussion thread, one can simultaneously hold Free Circle or Circle Card or Circle 360. In my mind, they are not three different memberships, but three different tracks (as they can be held simultaneously) under a single membership object (which corresponds to the larger Target Circle loyalty program). This is why in the proposal tracks are still needed at the negotiation/confirmation layer to make sure we can account for the multi-holding case.

  2. Future-proof expansion: I really like the simplification you proposed as it definitely looks cleaner if we just focus on the checkout/order capability (which is also the focus of this PR). However, I do want to admit that during the design, our thinking is the extension should be extensible enough to account for future cataloging use case for example, and it's better we don't need a breaking change to support that. As a result, we have "activated_tiers" and making tiers an array for example, that are indeed not useful at the negotiation/confirmation layer but needed for discovery/upsell stage. I'm not sure what's the general guideline here in terms of supporting immediate use case v.s. supporting future use cases without breaking change.

"description": "Benefits associated with a membership tier.",
"type": "object",
"required": ["id", "description"],
"properties": {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add type (open enum with reverse-domain extensions) and optional metadata. Without a discriminator, free-shipping / %-off / SKU-entitlement / event-access are all indistinguishable strings.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thought is if we categorize benefits into two buckets: monetary ones and informational ones, then I don't feel a strong need to have a type here. Main reason is as just IIya mentioned above: "Monetary effects (member pricing, free shipping) are already fully represented in discounts.applied[] via eligibility. The loyalty extension doesn't duplicate those — it carries the informational benefits." If we agree on this then the informational benefits here do not really need to be distinguishable as they are only for display purpose?

},
"description": "List of quantifiable rewards value the user holds. Each object encapsulates one type of reward the membership offers."
},
"provisional": {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shape gives each membership exactly one provisional / eligibility pair, but the spec later shows a checkout carrying two simultaneous eligibility claims (com.example.loyalty and com.example.loyalty.credit_card) that activate different benefits. That makes the claim-to-outcome mapping lossy for agents and gives us no place to represent “claim A verified, claim B failed” independently. I’d strongly recommend reshaping this around per-claim entries (for example, a map keyed by eligibility claim or an array where each element is scoped to one claim), with verification state and resulting tracks/benefits attached to that claim

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I think the key-val proposal IIya showed about works nicely in this case. Updated the definition of loyalty.json to expect a key-val map, with key being reverse_domain_name holding eligibility claims, and val being loyalty_membership holding membership information corresponds to the claim.

"type": "string",
"description": "Business specific name of the loyalty membership/program."
},
"member_id": {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Standardizing a raw member_id in the base loyalty response crosses a trust boundary that the spec does not currently define. Checkout eligibility claims are explicitly buyer claims rather than verified facts, and the broader spec treats PII / sensitive data conservatively. As written, this field can become a stable account identifier exposed to any negotiated platform/agent. I’d either remove it from the base schema, replace it with a display-safe masked identifier, or explicitly gate it behind an authenticated linkage / consent prerequisite in the normative text.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this insightful comment. Fully agree with the reasoning on the privacy aspect of concern. Updated schema to have masked display_id:

"display_id": {
  "type": "string",
  "description": "A masked or partial version of the membership id for user recognition (e.g., '****5678')."
},

In the meantime, I'm thinking if we still need some unique identifier to help platform and business for validation/correlation but without really exposing the user, via something like member_id_hash?

Comment thread source/schemas/common/types/membership_reward.json Outdated
"activated_tracks": {
"type": "array",
"items": { "type": "string" },
"description": "List of tracks that the customer is activated for, identified by the `id` of the Membership Track object."
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be singular object and not an array, especially if we are establishing an hierarchy for membership to tracks? If the loyalty.memberships is to highlight available membership programs provided by a merchant and tracks are flavors of that program (free, basic, premium etc), I'd assume a customer can be in only one flavor of the membership.

Copy link
Copy Markdown
Contributor

@gsmith85 gsmith85 Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mnaga I think you may be slightly conflating tracks and tiers here.

The intent of the design is that a member can be active on multiple tracks simultaneously, but holds only one active tier per track. A good real-world example is a corporate travel program where the same member participates in both a personal status track and an employer's corporate track under the same membership, each unlocking independent benefits.

In the spirit of your suggestion however, it's likely membership_track.activated_tiers should be singular rather than an array. Particularly if a "track" is the model which implies one active tier at a time.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it depends on how to interpret track and tier. To me,

  • track represents an enrollment pathway or program category that a user can join independently or simultaneously
  • tier represents specific achievement ranks or status milestones within a track that unlock escalating value as a member progresses through activity or spend.

Put this into real world examples:

  • Target offers a loyalty program that has multiple tracks (Target Circle - free, Target 360 - pay annual fee to join, Target card - credit card), and one can be in one or more tracks simultaneously (see this comment from Maxim in our previous PR feat: Add basic schema for loyalty extension #251 (comment)). In this case, each track will just have one tier, and overall shape looks like:
"tracks": [
  {
    "id": "track_1",
    "name": "Target Circle",
    "tiers": [
      {
        "id": "tier_1",
        "name": "Target Circle"
      }
    ]
  },
  {
    "id": "track_2",
    "name": "Target 360",
    "tiers": [
      {
        "id": "tier_2",
        "name": "Target 360"
      }
    ]
  },
  {
    "id": "track_3",
    "name": "Target Circle Card",
    "tiers": [
      {
        "id": "tier_3",
        "name": "Target Circle Card"
      }
    ]
  }
]
  • On the other hand, some other merchants have a loyalty program that offers multiple flavors (free, basic, premium) but buyer can only have one of them. In this case, I view them belonging to the single track but spans across different tiers:
"tracks": [
  {
    "id": "track_1",
    "name": "My Program",
    "tiers": [
      {
        "id": "tier_1",
        "name": "Free"
      },
      {
        "id": "tier_2",
        "name": "Basic"
      },
      {
        "id": "tier_3",
        "name": "Premium"
      }
    ]
  }
]

In short, the goal is to have some unified shape that can handle both cases, and that's why we introduced this (array) tracks -> (array) tiers hierarchy.

"items": { "type": "string" },
"description": "List of tracks that the customer is activated for, identified by the `id` of the Membership Track object."
},
"rewards": {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If tracks are actual entity that a customer is activating, I'd think rewards earned and forecast also need to be at a track level. Especially if we are using the same structure for upsell as well.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Yes rewards should be at tracks level. Updated the schema.

Comment thread docs/specification/loyalty.md Outdated

### Loyalty benefits behavior

An eligible membership is sometimes only a preliminary prerequisite of a member-only benefit and a verified claiming of loyalty membership does not necessarily result in a valid claim of associated member-only benefit. For example, in the event of a $50 order, the member-only benefit "Free shipping for all orders" applies while the other member-only discount "Save $10 with $100+ purchase" does not, assuming the buyer is an eligible member. As such, business MUST additionally surface all monetary price impacting benefits as provisional discounts using the `provisional` and `eligibility` fields within the `discounts.applied` object. When membership is valid but there are benefits that are inapplicable, businesses MUST communicate this via the message[] array. Depending on the type of inapplicable benefits (e.g. they are affecting the order totals), businesses can choose the message type between warning or info to get them surfaced.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec says: "business MUST additionally surface all monetary price impacting benefits as provisional discounts using the provisional and eligibility fields within the discounts.applied object." This is a hard dependency on dev.ucp.shopping.discount, but the discovery snippet at lines 87–103 does not declare it, and the schema's $defs."dev.ucp.shopping.checkout" composition does not reference the discount extension. A Business that advertises dev.ucp.common.loyalty without advertising dev.ucp.shopping.discount produces an undefined checkout — the loyalty spec mandates fields on a structure (discounts.applied) that will not exist in the response. Capability negotiation should refuse this combination or the loyalty spec should degrade gracefully without the discount extension. A "Dependencies" section in loyalty.md stating that loyalty price-impacting benefits require the discount extension to be negotiated, with a build-time/runtime check. If discount extension is not negotiated, loyalty MUST surface benefit names via messages[] with code: "eligibility_accepted" and no pricing impact.

Comment thread docs/specification/loyalty.md Outdated
Comment thread docs/specification/loyalty.md Outdated
"activated_tracks": {
"type": "array",
"items": { "type": "string" },
"description": "List of tracks that the customer is activated for, identified by the `id` of the Membership Track object."
Copy link
Copy Markdown
Contributor

@gsmith85 gsmith85 Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mnaga I think you may be slightly conflating tracks and tiers here.

The intent of the design is that a member can be active on multiple tracks simultaneously, but holds only one active tier per track. A good real-world example is a corporate travel program where the same member participates in both a personal status track and an employer's corporate track under the same membership, each unlocking independent benefits.

In the spirit of your suggestion however, it's likely membership_track.activated_tiers should be singular rather than an array. Particularly if a "track" is the model which implies one active tier at a time.

Comment thread source/schemas/common/types/loyalty_membership.json Outdated
Comment thread docs/specification/loyalty.md Outdated
@ziwuzhou-google ziwuzhou-google force-pushed the feat/loyalty-v2 branch 2 times, most recently from 91cc6be to 94cc1e4 Compare April 29, 2026 05:06
Copy link
Copy Markdown
Contributor

@igrigorik igrigorik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ziwuzhou-google ty, we're heading in the right direction. First pass & set of comments and questions, ptal below.

nit: can you please reformat to match line length / wrap conventions.

Comment thread docs/specification/loyalty.md Outdated
Comment thread docs/specification/loyalty.md Outdated

## Eligibility Claims

Given almost everything related to loyalty is provisional and requires eligible membership before benefits can be applied, [Context](checkout.md#context) naturally fits here where it allows buyers to claim eligibility for loyalty benefits and businesses to verify and communicate the result with the associated effects (financial-wise and rewards-wise). When loyalty extension is active and the request contains eligibility claims about loyalty, businesses that choose to accept eligibility claims MUST surface that as an indicator of buyer’s membership status, and if applicable, effects on pricing coming from monetary benefits. Platforms MUST display those provisional loyalty discounts to the buyer. Specifically, the loyalty extension is an object keyed by reverse_domain_name — same convention as services, capabilities, and payment_handlers in the business profile. The key is the eligibility claim, and value is the membership object that contains provisional to indicate the verification result.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This prose reads like a design doc, not a declarative spec. I think you can drop first sentence entirely and lead with "When extension is active, ... "

When loyalty extension is active and the request contains eligibility claims about loyalty, businesses that choose to accept eligibility claims MUST surface that as an indicator of buyer’s membership status, and if applicable, effects on pricing coming from monetary benefits.

There is an implicit dependency here on auth. This can't be a must in anon/guest user flow. Platforms can display provisional benefits, but we need to have a clear two track separation between: provisional claims vs verified claims.

Copy link
Copy Markdown
Author

@ziwuzhou-google ziwuzhou-google Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion. I think the original wording is a bit confusing and vague. Updated to the following:

When loyalty extension is active and the request contains eligibility claims about loyalty, businesses that recognize those eligibility claims MUST return them as the keys of the loyalty extension, which is an object keyed by the reverse-domain identifier — same convention as services, capabilities, and payment handlers in the business profile. The values of the returned object contain detailed membership info corresponding to the claims, and specifically contains a provisional field to indicate the status of the claim.

If monetary price impacting loyalty benefits (e.g. member pricing/shipping) are available and discount extension is supported by both business and platform, businesses MUST set the same provisional value in each of applied obejct within discount extension as in the loyalty extension. Platform can then follow the same rendering pattern for discount extension to surface these loyalty benefits to buyers.

I've also updated the example in this section to explain what the word means. In the meantime, I want to make sure I fully understand your second part of the comment:

There is an implicit dependency here on auth.

If the loyalty benefits truly needs to be applied (e.g. becomes a non-provisional benefit during the complete-checkout stage), then yes business need to verify the claim (if there is any) by knowing who the buyer is and that is done through account-linking/direct user sign-in from platform side/tokenized identity sharing etc (basically the auth part).

This can't be a must in anon/guest user flow. Platforms can display provisional benefits

If I understand correctly, before the auth mentioned above happens, the user is anon/guest to the business. In this case, platform can still display the loyalty benefits (and it is up to platform to render disclaimers on the front-end to remind buyer that some benefits needs further verification).

but we need to have a clear two track separation between: provisional claims vs verified claims.

Yes, I think our current model works. The provisional field under the loyalty and discount extensions will help to distinguish these two cases.

Comment thread docs/specification/loyalty.md Outdated
Comment thread docs/specification/loyalty.md Outdated
Comment thread docs/specification/loyalty.md Outdated

### Loyalty behavior

Platforms MUST send buyer loyalty membership claims via `context.eligibility` to activate loyalty extension and claim for loyalty benefits. The key of the returned object within loyalty extension represents that buyer's claim to the loyalty program. If a business successfully verifies this claim, the business MUST update the `provisional` boolean to false and populate the `activated_tier` fields alongside the to reflect the buyer's verified status for that specific claim.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is eligibility a must? I would expect that an authenticated user could/would see loyalty info without having to explicitly claim anything. The business lookups identity and associated loyalty benefits, and echoes it.

context.eligibility is another path in, required when you're not authed and optional when you are -- e.g. you're claiming that you'll use a special credit card that gives you free shipping benefits.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is no explicit claims in the request (but business already knows who the buyer is via auth), I agree business can directly associate benefits back in the response when the user is truly a member. However, what will be the key (holds the reverse domain typed claim) there for the loyalty extension? I would think provisional should be set as false in this case but should we expect business to directly fill in the corresponding claim value that matches to loyalty automatically?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As written, this MUST means a business cannot populate the loyalty extension for an authenticated user unless the platform also sends context.eligibility -- which creates a redundant round-trip when the business already knows who the buyer is.

I'd propose something like: "Platforms MUST send buyer loyalty membership claims via context.eligibility to claim loyalty benefits. Alternatively, when the buyer is authenticated and the business can determine loyalty membership from the authenticated identity, businesses MAY populate the loyalty extension without an explicit eligibility claim. In this case, the map key MUST be the same reverse-domain identifier the business would accept as a claim value, and provisional MUST be false."

This is a real flow for Target -- a signed-in Target Circle member should see their benefits immediately without needing the agent to separately claim com.target.loyalty.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After some more thinking and reading through the original PR for context, I agree that the current writing is not fully accurate. As pointed by both of you, business should be able to activate the loyalty extension even if there is no context.eligibility in the request, as long as they are able to verify the buyer. After that, business should be able to populate the key of the loyalty extension that holds the reverse domain name the user is activated for, as they are also the ones advertising the claim strings.

Updated the section to be more clear.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 let's make it clear that auth as well as auth+claims both can be the relevant input signals for the business to return loyalty benefits.

Comment on lines +481 to +484
"breakdown": [
{ "source": "$.tiers[0].benefits[1]", "amount": 10 },
{ "source": "$.tiers[0].benefits[2]", "amount": 20 }
]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UCP JSONPaths are root-relative everywhere ($.line_items[0], $.totals[*] in discount.allocations). These are scoped to the parent track.

earning_breakdown lost id and description from earlier version? Per-rule rationale ("2x on footwear = 20 pts") now lives only in the benefit's freetext description, reachable via dereference. An agent rendering the forecast must (a) resolve the path, (b) read freetext, (c) parse heuristically. The agent rendering layer just got harder for the most marketing-critical loyalty signal.

Should we restore description on earning_breakdown so businesses can provide rendered-friendly per-rule text without forcing dereference.

Copy link
Copy Markdown
Author

@ziwuzhou-google ziwuzhou-google May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I was debating when I worked on this part as well: with the use of source the schema looks a bit more compact, but in the meantime forcing a rendering agent to resolve a JSONPath and then "scrape" or heuristically parse a description just to show a line-item explanation is a recipe for brittle UIs. Now that you share the same concern, I'm convinced that we should just use a simple description. In the meantime, I will still keep the source (but no longer required) as a fallback just for "Read More" links or deeper audit trails. Also added the id field back for easy rule traceability.

{"type": "total", "display_text": "Estimated Total", "amount": 1000}
]
}
```
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing sections..

  • security considerations
  • privacy considerations
  • error codes (we define a few in prose)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the error codes based on another comment.

Based on my understanding, unlike capability, this extension does not have operations and only defines the data shape. I don't think security applies?

For privacy, can you share which aspects are you looking for? The only part that I can think of for loyalty is the user membership id that we defined in the schema. If not handled properly, this could be used as an unique identifier by the platform and creates some privacy concerns. Therefore it was updated to be a masked id.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would add a short section before the examples end stating that loyalty responses MUST be data-minimized, MUST NOT expose raw stable member identifiers, MUST only include display_id after verified/authenticated membership, and MUST treat all context.eligibility values as buyer claims rather than proof.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a Implementation guidelines section to include all these suggestions.

Comment thread source/schemas/common/types/balance_currency.json Outdated
Comment thread source/schemas/common/types/loyalty.json Outdated
@@ -0,0 +1,20 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://ucp.dev/schemas/common/types/membership_balance.json",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Meta: we broke everything into discrete files, but pretty much everything here is self-contained to one domain and not reused anywhere else. Can we collapse these types into $defs under unified schema?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.

Comment thread docs/specification/loyalty.md Outdated
Comment thread docs/specification/loyalty.md Outdated
@ziwuzhou-google
Copy link
Copy Markdown
Author

ziwuzhou-google commented May 2, 2026

@ziwuzhou-google ty, we're heading in the right direction. First pass & set of comments and questions, ptal below.

nit: can you please reformat to match line length / wrap conventions.

@igrigorik @maximenajim thank you for your thorough and insightful comments. I've updated the PR based on those, and left some questions as well. PTAL.

Regarding the format, it's a bit weird that I do have the pre-commit installed on my side (following https://github.com/Universal-Commerce-Protocol/.github/blob/main/CONTRIBUTING.md#linting-and-automated-checks), and the linter checks do run when I git commit. However, I don't see line length / wrap changes at all.

@sinhanurag
Copy link
Copy Markdown

sinhanurag commented May 4, 2026

Summarizing some of the principles we have discussed and aligned on in the TC meetings here:

  1. The loyalty extension structure we use here should work across use cases applicable to checkout/order or to use cases such as loyalty tier upsells, profile and identity management specifying different loyalty tiers. So for example using a tracks or a tiers list is fine with the restriction that it will possibly only have 1 element pertaining to the activated track or tier during checkout and can have multiple in cases where the agent queries possible membership structures.
    Effectively we don't want to create redundant structures for loyalty across different capabilities. Hence, this extension structure should be extensible beyond checkout and order.

  2. The loyalty extension is responsible for earning forecast part of the loyalty interaction. Effectively meaning that information about how many points/miles etc can be earned as part of the checkout would be specified through the loyalty extension. However, constructs such as available_balance, redemption etc will pertain to payments instruments that can used to split payments. This is a more elegant modeling where gift cards, loyalty points etc. can all coalesce in the same streamlined structure.

  3. For the loyalty extension to work we need account linking and in some cases a passed in context.eligibility signal in the request. There is no hard dependency on the latter (context.eligibility) signal for all loyalty benefits

-cc @igrigorik @ziwuzhou-google @raginpirate @maximenajim @mnaga fyi

@ziwuzhou-google
Copy link
Copy Markdown
Author

Summarizing some of the principles we have aligned on in the TC meetings here:

  1. The loyalty extension structure we use here should work across use cases applicable to checkout/order or to use cases such as loyalty tier upsells, profile and identity management specifying different loyalty tiers. So for example using a tracks or a tiers list is fine with the restriction that it will possibly only have 1 element pertaining to the activated track or tier during checkout and can have multiple in cases where the agent queries possible membership structures.
    Effectively we don't want to create redundant structures for loyalty across different capabilities. Hence, this extension structure should be extensible beyond checkout and order.
  2. The loyalty extension is responsible for earning forecast part of the loyalty interaction. Effectively meaning that information about how many points/miles etc can be earned as part of the checkout would be specified through the loyalty extension. However, constructs such as available_balance, redemption etc will pertain to payments instruments that can used to split payments. This is a more elegant modeling where gift cards, loyalty points etc. can all coalesce in the same streamlined structure.

-cc @igrigorik @ziwuzhou-google @raginpirate @maximenajim @mnaga fyi

Thank you @sinhanurag for the summary! Updated the PR to remove all balanced related concepts in the schema/documentation.


## Discovery

Businesses can follow standard advertisement mechanism to advertise loyalty support in the Business profile. Currently loyalty extension can ONLY decorate checkout capability and thus the profile should contain both.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why we are decorating only checkout capability and not cart and catalog capabilities as well? Surfacing price and shipping benefits early on during the journey would impact customers decisions

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on my understanding of the past few TC deep dive, we want to laser focused on checkout capability for this PR for now. Extending it to cart/catalog capabilities in the upper funnel discovery/upsell should be a follow-up that is not difficult (I agree that is extremely helpful and that is why we want to keep the shape extensible enough). I can raise another PR immediately after this one to discuss how it can be extended to support cart/catalog.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 @mnaga as per our conversation I sent you some examples offline of how the structure will look like.

}
],
"activated_tier": "tier_1",
"rewards": [
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the intent of rewards to show potential forecast for all potential tiers? If so, array makes sense but would require a source field to map to the right tier.
If the intent is to provide forecast for activated tier only, dont see a reason for rewards to be an array.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason is because one tier can have multiple types of reward. For example in travel vertical (United Airline for example), you can earn miles + United Mileage Plus point if you are their highest tier member. These two should be casted as two types of rewards, each with it's own forecasted earning amount.

Comment thread docs/specification/loyalty.md Outdated
Comment thread docs/specification/loyalty.md Outdated
Comment thread docs/specification/loyalty.md Outdated
Copy link
Copy Markdown
Contributor

@raginpirate raginpirate left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate that payments was generally pushed out of scope here; some nits I spotted around unifying with base UCP primitives such as currency and amount 😄

"name": "dev.ucp.common.loyalty",
"title": "Loyalty Extension",
"description": "Extends various Capabilities with loyalty support using memberships info.",
"$defs": {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this PR ships a bunch of structures into one big json blob; shouldn't those be separated out?

Copy link
Copy Markdown
Author

@ziwuzhou-google ziwuzhou-google May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These defs were separated into sub-files (under common/types). IIya suggested a concentration within this file. See #340 (comment). I do agree that these structures are pretty loyalty-specific for now. We can separate them out when we continue to expand the supported functionalities for loyalty and find more generalization. WDYT?

Comment thread source/schemas/common/loyalty.json
Comment thread source/schemas/common/loyalty.json
}
}
},
"reward_currency": {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a missing primitive here to unify this with base currency descriptions across UCP? I feel like checkout and loyalty should be sharing the same conceptual way to advertise a currency.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not 100% sure actually. Right now the currency in the rest of UCP is fiat currency using ISO 4217 code while for loyalty it is a customized version. Fiat currency does not need explicit name and decimal places as both can be deduced from the currency code, while for loyalty currency we do have to have them explicitly.

I did some research with the help of LLM and the industry splits this problem into three core architectural patterns when modeling standard fiat vs. customized (loyalty) currencies:

  • The Decoupled Ledger Model (Salesforce): For Standard Commerce, it uses dw.util.Currency and CurrencyType which is strictly tied to ISO 4217, and API only exposing IsoCode and DecimalPlaces as read only; For Loyalty, it uses LoyaltyProgramCurrency which has a similar definition as we have
  • The "Everything is Fiat" Model (Shopify): It relies on CurrencyCode Enum & MoneyV2 Object, and its standard pricing fields only accept a Decimal amount and a strict CurrencyCode enum (which strictly lists ISO 4217 codes). And because Shopify's native checkout cannot represent points, loyalty platforms (like Okendo, Bold, or Yotpo) must run entirely as external databases. When a user checks out, the loyalty engine must translate the custom currency into standard fiat and get injected into the checkout as standard fiat money
  • The Extended Registry Model (Oracle CX Commerce & Stripe Apps): These systems allow custom currencies to live alongside standard ones by using a registry where custom currencies supply their own formatting rules. For example Oracle CX Commerce allows merchants to define a custom loyalty currency directly within the core currency API (/ccadmin/v1/currencies)

If we want to introduce a unified currency modeling (and non-breaking backward compatible change), maybe we can do something like this:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://ucp.dev/schemas/common/types/currency.json",
  "title": "Currency",
  "description": "Unified representation of standard (fiat) or custom (loyalty) currency.",
  "type": "object",
  "required": ["code"],
  "properties": {
    "code": {
      "type": "string",
      "description": "ISO 4217 code (e.g. 'USD') or custom loyalty code (e.g. 'LST')."
    },
    "name": {
      "type": "string",
      "description": "Human-readable name of the custom currency (e.g. 'LoyaltyStars')."
    },
    "decimal_places": {
      "type": "integer",
      "description": "The position of a digit to the right of a decimal point for custom currency amounts."
    }
  },
  "$defs": {
    "fiat": {
      "allOf": [
        { "$ref": "#" }
      ],
      "properties": {
        "code": {
          "pattern": "^[A-Z]{3}$"
        }
      },
      "not": {
        "anyOf": [
          {"required": ["name"]},
          {"required": ["decimal_places"]}
        ]
      },
      "description": "For standard fiat currencies, 'name' and 'decimal_places' must NOT be set. Decimals must be resolved via ISO 4217 registry."
    },
    "custom": {
      "allOf": [
        { "$ref": "#" }
      ],
      "properties": {
        "decimal_places": {
          "type": "integer",
          "default": 0,
          "description": "Custom currencies default to 0 decimal places (integers) if not specified."
        }
      },
      "required": ["name"],
      "description": "For custom loyalty/rewards currencies, 'name' is strictly required. If 'decimal_places' is omitted, it defaults to 0."
    }
  }
}

Now in the current UCP schema where currency is defined (price.json/cart.json/order.json etc), the currency can be replaced with the following for backward compatibility:

"currency": {
    "oneOf": [
      {
        "type": "string",
        "description": "Simple string representation (e.g. 'USD') using ISO 4217 currency code."
      },
      {
        "$ref": "../../common/types/currency.json#/$defs/fiat",
        "description": "Structured fiat representation."
      }
    ]
  }

And the the reward_currency in this PR simplifies down to pointing directly at the custom definition:

"reward_currency": {
  "$ref": "types/currency.json#/$defs/custom",
  "description": "The custom loyalty reward currency."
}

Let me know how this looks to you.

Copy link
Copy Markdown
Contributor

@igrigorik igrigorik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're getting close, thanks for pushing this along! Pushed a few cosmetic fixes, but as I come back to this with fresh eyes, a few flags and questions...

A: Loyalty should work across catalog, cart, checkout, and post-order

Right now schema binds only dev.ucp.shopping.checkout. Loyalty spans the entire lifecycle: cart needs it (member pricing on preview), orders need it (rewards earned, member purchase history), catalog needs it (member-only items, member PDP pricing). Assuming we stay with current data model of exteding context and emitting a new top-level key, the spec work to make this work is small. I would strongly prefer we land the whole story, not piecemeal.

B: unnecessary dependency on discount extension

Monetary loyalty benefits in current prose and schemas assume that discount extension is negotiated. I have no objections to loyalty layering discount schemas (in fact, it's the right thing to do), but I would prefer that we remove this as a strict dependency. Even if the business does not advertise and support discount extensions, loyalty should be able to function on its own. We should invert the dependency, roughly...

Price-impacting loyalty benefits MUST surface via the base capability's totals / line_items[].totals (always present on cart + checkout) — type: "items_discount" with display_text attributing the loyalty source. When discount extension is also active, business MAY additionally populate discounts.applied[] with eligibility claim for richer attribution; the provisional mirroring rule narrows to a conditional ("when discount is also active"), not a soft-required.


C: loyalty is a response-only field on catalog/cart/checkout/order?

Right now ucp_request is on the wrong layer AND, I believe, has the wrong values.

For $defs.dev.ucp.shopping.checkout definition, we currently have {create: optional, update: optional, complete: omit}. This permits the platform to send a full loyalty_membership payload on create/update requests — but the platform is not authoritative for this data. Claims travel via context.eligibility (already in the base capability); loyalty should be a response only field based on business's resolution of the claim / user identity.

Do we agree this is the right contract? At a later point we'll need to discuss how to model loyalty sign-up / purchase, but that's a whole separate conversation, we should not create/allow this data as input on current surface, and I don't think this is the right long-term surface to negotiate sign-up / purchase of loyalty programs.


D: Response shape violates UCP's advertise / negotiate / confirm convention

We've done a few turns on this with @sinhanurag and — apologies upfront for the turbulence — I think 'one data model' lands us in a client-suboptimal and spec inconsistent state. Hear me out... 🤓

The loyalty response in the checkout/cart hot path returns a full tracks[].tiers[] enumeration plus an activated_tier pointer back into the array — when the buyer's tier is a determined fact, not a selectable option. This conflates a discovery/lookup shape with a runtime confirmation shape, and pays for the mistake on every catalog/cart/checkout/order call.

Current shape:

"loyalty": {
  "com.example.loyalty": {
    "id": "membership_1",
    "name": "My Loyalty Program",
    "tracks": [
      {
        "id": "track_1",
        "name": "track_name_1",
        "tiers": [                            ← full tier enumeration
          { "id": "tier_1", "name": "Gold", "benefits": [...] }
        ],
        "activated_tier": "tier_1"            ← pointer back into tiers[]
      }
    ],
    "provisional": false
  }
}

The platform must compute the answer with tiers.find(t => t.id === activated_tier) to render anything. The unselected-tier slots in tiers[] carry no transaction-relevant payload and semantically do not make sense: why are we listing and allowing presence of tracks and tiers that buyer cannot affect?

If we squint, the convention we have in UCP right now has three interaction states:

State When Audience Shape
Advertise Discovery, pre-transaction Anyone discovering the business/platform Full enumeration of what's possible
Negotiate Mid-transaction, while a decision is pending Buyer/agent making a choice Menu of remaining options + pointer to current selection (if any)
Confirm Per-transaction response, after determination Platform rendering authoritative state Only the resolved/active row(s)

Payment handlers is the canonical advertise vs. confirm split.payment_handler.json defines three schema variants via business_schema, platform_schema, and distinct response_schema. The handler advertises available_instruments[] (what's possible). The runtime response carries instruments[] with selected: true markers (what was actually picked), because its a negotiable artifact. Two different fields, two different shapes, two different lifecycles.

Fulfillment is the canonical negotiate shape. fulfillment_method.json and fulfillment_group.json use the verbose-with-pointer pattern that loyalty currently mimics:

"groups": [{
  "options": [                                ← full menu
    { "id": "opt_std", "title": "Standard $5", ... },
    { "id": "opt_exp", "title": "Express $15", ... }
  ],
  "selected_option_id": "opt_std"             ← pointer
}]

But the pointer-based menu is here for a reason: shipping selection is interactive. The buyer/agent must choose between Standard, Express, and Free. The response carries the menu because the decision is pending — subsequent update calls echo it back with selected_option_id populated as the buyer picks. Same holds for destinations: a buyer with multiple saved addresses sees destinations[] + selected_destination_id — a real menu, a real pick.

Fulfillment's verbose shape is the negotiate state: appropriate when alternatives are a buyer/agent choice.

Loyalty doesn't fit 'negotiate' pattern

Loyalty has no selection step. A buyer doesn't pick "I want to be Gold today": their tier is determined by the business. There is no menu, no decision, no selected_*_id to populate over negotiation lifecycle. The platform/buyer can make a different claim or provide their identity, but output of loyalty is the computed confirmation emitted by the business. Loyalty is purely Confirm, and the shape should reflect that unambiguously.

We landed on the current shape to preserve symmetry: the same tracks[].tiers[] enumeration we return at checkout could one day appear at a "list all tiers" lookup endpoint, and integrators would learn one model. The symmetry is appealing, but with fresh eyes, three problems:

  1. It contradicts the payment handler precedent. Payment handlers explicitly use different shapes for advertise (business_schema) and confirm (response_schema), with normative MUSTs about which is authoritative when. Loyalty inheriting one shape across discovery and runtime is the opposite of what we already do elsewhere.
  2. A response without a selection event has nothing to negotiate. When the buyer has no decision to make, returning a menu is misleading: unselected entries have undefined semantics (already-achieved? eligible-but-not-claimed? listed by accident?), and the wire shape encodes a question that was never asked. The right shape for "this is determined" is the determined value, not a menu pointing at it.
  3. Implementation cost is real and recurring. These responses land on every catalog/cart/checkout/order call. Every integrator consuming loyalty has to write pointer-resolution code (tiers.find(t => t.id === activated_tier)) to extract the active row from a shape pretending to be a selection — no menu to pick from, no selected_*_id to populate over a lifecycle. The mental cost of "this looks like a selection game but isn't" compounds across every platform — paid for a value the business has already computed, for a reason that doesn't apply.

What it could / should look like...

"loyalty": {
  "com.example.loyalty": {
    "id": "membership_1",
    "display_id": "****5678",
    "name": "My Loyalty Program",
    "tracks": [
      {
        "id": "earned",
        "name": "Earned",
        "tier": {                            ← singular: the determined tier
          "id": "gold",
          "name": "Gold",
          "benefits": [
            { "id": "BEN_001", "description": "Early access to sales" }
          ]
        },
        "rewards": [...]
      }
    ],
    "provisional": false
  }
}

Changes vs current shape:

  • tracks[].tiers[] (array, plural) → tracks[].tier (object, singular): the track entry's existence implies activation; the tier object on the track is the active tier. No pointer indirection.
  • Drop tracks[].activated_tier field: unnecessary with the singular tier object above it.
  • tracks[] plurality: open question, see below.
  • Inactive tracks (expired, eligible-but-not-claimed) are implicitly absent from tracks[] and no "active flag" needed.

Question: is tracks[] plurality appropriate in this response?

The doc justifies plural tracks with: "Allows for parallel participation, enabling a customer to belong to multiple tracks at once and aggregate their respective benefits (e.g., simultaneously holding 'Premium' status in a delivery track and 'Gold' status in a spend track)." What's a real-world example of this? Most programs I'm aware of are single-track multi-tier; where multi-track-like behavior exists, it tends to be modeled as separate memberships rather than tracks-within-a-membership.

Could/should those be modeled as separate top-level eligibility keys (com.example.loyalty.delivery, com.example.loyalty.spend), piggybacking on the map object we already have?

  • A: singular: track {} per membership; multi-track scenarios use multiple loyalty.<eligibility> entries — piggybacks on the map plurality we already have.
  • B: current: tracks[] plural per membership; one membership entry contains multiple track objects.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

TC review Ready for TC review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants