Proposed (STA-927, batch stackra-53). It closes the Commerce loyalty gaps (customers loyalty SPEC,
“Stackra gaps”) and the consent-on-behalf row of the package needs (OS-284). Loyalty points stay
on
@stackra/loyalty-kit (OS-343; ADR 0357’s Note of 2026-10-26).Context
Commerce’s loyalty module found five gaps in the kit: there is no programme (one account per scope and member, and nothing a merchant can pause); the earn rate, point value and redemption cap are deploy-time config, not per-scope settings; tiers are fixed to the kit’s balance thresholds inRecalculateTierListener, with no way for a host to
apply its own tier rules; points.redeemed is a private domain event and expiry publishes nothing, so a host cannot
follow every balance move; and the catalogue planned loyalty.points.earned while the kit publishes
loyalty_kit.points.earned. Separately, products need server-side consent evidence (who answered, which version,
when, on whose behalf) beyond the visitor’s cookie decision of ADR 0298.
Decision
- A programme is the kit’s settings group resolved at a scope.
LoyaltyKitSettingsgainsisActive(defaulttrue). One programme per scope node: a tenant’s settings are its programme, and a per-brand programme (COM-181) is a scope level below the tenant that the host contributes, resolved by the settings cascade (ADR 0213). No programme table: accounts stay one per (scope, member). A paused programme earns and enrolls nothing (spend and visit earns return zero); redemption and expiry go on, so a member never loses points to a pause. - The earn rate is a setting.
LoyaltyKitSettings.earnPerMinor(default0.01, at most1, approval required, since it is a member benefit) is the rateAccountService.earnuses.LoyaltyService.quoteEarn(amountMinor, earnPerMinor?)takes the scope’s rate; configearnPerMinorremains only as the fallback of an isomorphic preview with no settings reader, and is to be removed once no caller relies on it. Point value and the redemption cap stay config in this ADR (they are read by the isomorphicquote); moving them is a follow-up. - Tiers are a port,
LoyaltyTierPolicy.RecalculateTierListenerasks the policy which tier an earn earns (tierFor({ memberId, current, balanceAfter })) and applies the answer only to raise a tier, never to demote. The kit’s one driver,threshold, readsLoyaltyKitSettings.tiersand is the behaviour the kit had before. A host replaces it with@Replaces(LoyaltyTierPolicy), which is the hook that replaces the threshold listener’s rule. - Every balance move is an integration event, named in gen form. Event keys follow the casing rules: the module
key is the package key, one snake_case segment, so the kit’s events are
loyalty_kit.<model>.<verb>.v<n>; the catalogue’sloyalty.points.earnedis the name to change, not the kit’s.PointsRedeemedbecomes an integration event,loyalty_kit.points.redeemed.v1, withbalanceAfter; a newPointsExpired,loyalty_kit.points.expired.v1(memberId,points,cutoff,balanceAfter), is recorded per account whose expiry took points.loyalty_kit.points.earned.v2is unchanged. The balance query’s cache drops on all three. - Consent evidence records are
ConsentProofrows. Each row already holds who it is about (subject), who gave it (givenBy), on whose behalf (relationship, theConsentAuthority’s answer, never input), the document and version, the text’s hash, the answer, the instant and the channel; the store is append-only.ConsentProofServicegainsevidence(subject, document?), the subject’s trail oldest first, as written, so it can be shown to the person or a regulator. The visitor cookie (ADR 0298) stays a preference, never evidence.
Consequences
- Consumers of the renamed wire name: anything listing
loyalty_kit.points.redeemed(no version) must readloyalty_kit.points.redeemed.v1; in Commerce that isapps/docs/src/api-reference/openapi.json(invalidateOn) andservices/customers/src/modules/loyalty/SPEC.md. Commerce’sPromoteOnPointsEarnedListenercan become aLoyaltyTierPolicyreplacement, and itsloyalty.pausedhandling can readisActive. AccountServicetakes aSettingsReader;RecalculateTierListenertakes aLoyaltyTierPolicyinstead of one. Both are resolved by type, so gen wiring needs no change beyond discovering the new driver.- A consent verification method (card check, signed form; review A2-2) is not part of the record yet; it is a follow-up port.
Alternatives
- A programme table with its own id on every account and entry. Rejected for now: it needs a migration of every loyalty table and a new key on the ledger, while the settings cascade already gives one value set per scope.
- An
afterextension slot for the tier. Rejected: the listener runs after commit, outside an action call, so it has no extension context; a port replaced by type fits the kit’s existing@Replaces(LoyaltyLedger)pattern. - Renaming the kit’s events to
loyalty.*. Rejected: the module key is derived from the package (ADR 0294 decision 20), and a rename would need@Renamedon every event for no gain.
Supersedes and sources
Supersedes nothing. Sources: Commerceservices/customers/src/modules/loyalty/SPEC.md (“Stackra gaps”); Commerce
STA-716, STA-713; OS-343; OS-284; ADR 0213, 0298, 0357.