Context
Points reward volume; challenges reward behaviour. That difference is what lets a tenant shape habits — visit on a Tuesday, try a new category, complete a profile — rather than only rewarding whoever already spends the most. Two effects from behavioural research shape the design and are worth naming because they change the schema rather than only the copy. Goal gradient: effort increases as a visible goal gets closer, so progress must be visible and expressed as a fraction, not a raw count. Endowed progress: a challenge that starts at 2 of 10 completes measurably more often than one that starts at 0 of 8, which is why an initial-credit field exists. Schema is reserved from the first migration so this needs no migration when it lands.Scope (normative)
loyalty.badges: permanent, non-consumable achievements with criteria.loyalty.badge_awards: append-only award history.loyalty.challenges: time-boxed goals with progress, an initial credit and a reward on completion.loyalty.challenge_progress: per-member progress, incrementally updated.- Criteria reusing the segment DSL (ADR-012) rather than a second condition language.
- Member-facing progress reads.
Non-scope (normative)
- Leaderboards and any member-to-member comparison. Ranking members against each other exposes behavioural data about one person to another and is a privacy question, not a feature gap.
- Streaks. Deliberately deferred: they are the most punishing mechanic to get wrong, since one missed day destroying a 90-day streak generates real anger.
- Badge artwork and asset management. Badges carry an asset reference; storing images is not this module’s job.
Behaviour (normative)
- Badges are permanent and never revoked by expiry. A badge is a record of something that happened; only fraud reversal removes one, and that is audited.
- A badge is awarded at most once per member, enforced by a unique constraint.
- Challenges are time-boxed. Progress outside the window does not count, and the window is visible to the member.
- Progress is stored as
currentandtargetso the fraction can be rendered directly. Storing only a raw count would make every client recompute it. initial_creditis applied at enrolment and is included incurrent— the member sees progress before doing anything.- Completion is evaluated incrementally on each qualifying event, never by a nightly sweep. A member who completes a challenge must see it immediately.
- Completion emits once and awards its reward through the existing effect executor. Re-evaluating a completed challenge is a no-op.
- Criteria use the segment DSL. Introducing a second condition language here is FORBIDDEN.
Data (normative)
API (normative)
Events (normative)
loyalty.badge.awarded and loyalty.challenge.completed, both available as outgoing webhooks and
both natural triggers for a congratulation campaign.
Acceptance criteria (normative)
- A badge is awarded at most once even under 50 concurrent qualifying events.
- Progress on a challenge with
initial_credit = 2andtarget = 10starts at 2 of 10. - Progress outside the challenge window does not increment.
- Completion emits once; re-evaluating afterwards emits nothing and awards nothing.
currentcan never exceedtarget— proven by the check-constraint violation test.members/me/achievementsp95 <150 ms with 50 badges and 10 active challenges.- Negative: no endpoint returns another member’s progress or badges.
Execution
Asynchronous pipeline, evaluated in the same processor pass as the rules engine so a single event updates points, tiers and challenge progress together.Open questions
None outstanding. Every question this spec carried was answered in the consolidated register (../../../design/open-questions-v1.md, v1.1) and folded
into the normative sections above.