Skip to content

chore: backport loyalty extension to 2026-04-08 release - #813

Merged
damaz91 merged 2 commits into
Universal-Commerce-Protocol:release/2026-04-08from
damaz91:chore/backport-loyalty
Sep 9, 2026
Merged

chore: backport loyalty extension to 2026-04-08 release#813
damaz91 merged 2 commits into
Universal-Commerce-Protocol:release/2026-04-08from
damaz91:chore/backport-loyalty

Conversation

@damaz91

@damaz91 damaz91 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Description

Backports the loyalty extension (#340 / commit 240097bcbc31ab3c19b0bb8fad03a07379eea33d) to the release/2026-04-08 branch.

Adjustments for 2026-04-08 Release:

  • Adjusted $ref in source/schemas/common/loyalty.json from types/reverse_domain_name.json to ../shopping/types/reverse_domain_name.json to match the type location in the 2026-04-08 release.
  • Added source/schemas/common to SCHEMAS_DIRS in main.py so MkDocs macros (extension_schema_fields) can resolve schemas under common/.
  • Fixed CI ucp-schema caching in .github/actions/setup-build-env/action.yml using cargo info (matching ci: fix ucp-schema caching #522).
  • Bumped js-yaml and fast-uri in package.json / package-lock.json to resolve Super-linter CVE-2026-53550.

Category (Required)

  • Core Protocol: Changes to the base communication layer, global context, or breaking refactors. (Requires Technical Council approval)
  • Governance/Contributing: Updates to GOVERNANCE.md, CONTRIBUTING.md, or CODEOWNERS. (Requires Governance Council approval)
  • Capability: New schemas (Discovery, Cart, etc.) or extensions. (Requires Maintainer approval)
  • Documentation: Updates to README, or documentations regarding schema or capabilities. (Requires Maintainer approval)
  • Infrastructure: CI/CD, Linters, or build scripts. (Requires DevOps Maintainer approval)
  • Maintenance: Version bumps, lockfile updates, or minor bug fixes. (Requires DevOps Maintainer approval)
  • SDK: Language-specific SDK updates and releases. (Requires DevOps Maintainer approval)
  • Samples / Conformance: Maintaining samples and the conformance suite. (Requires Maintainer approval)
  • UCP Schema: Changes to the ucp-schema tool (resolver, linter, validator). (Requires Maintainer approval)
  • Community Health (.github): Updates to templates, workflows, or org-level configs. (Requires DevOps Maintainer approval)

Related Issues

Backports #340

Checklist

  • I have followed the Contributing Guide (including Conventional Commits title requirements and ! for breaking changes).
  • I have updated the documentation (if applicable).
  • My changes pass all local linting and formatting checks.
  • 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.
  • (For Core/Capability) I have included/updated the relevant JSON schemas.
  • I have regenerated Python Pydantic models by running generate_models.sh under python_sdk.

Screenshots / Logs (if applicable)

  • Built and validated full documentation across all versions with ./scripts/build_local.sh.
  • Validated all internal links with check_links.py.
  • Verified schemas with ucp-schema lint and ucp-schema resolve.
  • All GitHub Actions workflows passing (Docs Build and Deploy, Lint Code Base, Conventional Commits, PR Governance, cla/google).

@damaz91
damaz91 requested review from a team as code owners September 8, 2026 19:47
@damaz91
damaz91 requested review from mmohades, tormin-google, wry-ry and yanheChen and removed request for a team September 8, 2026 19:47
@google-cla

google-cla Bot commented Sep 8, 2026

Copy link
Copy Markdown

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
damaz91 force-pushed the chore/backport-loyalty branch from 58e377e to 133b31f Compare September 8, 2026 19:53
@damaz91
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
@damaz91 damaz91 added status:under-review duplicate This issue or pull request already exists and removed duplicate This issue or pull request already exists labels Sep 8, 2026
@damaz91
damaz91 merged commit a25a4a2 into Universal-Commerce-Protocol:release/2026-04-08 Sep 9, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants