> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Gamification — Badges and Challenges

> After this ships, a member can be working toward something specific — "three visits this month unlocks a free coffee" — rather than only accumulating a number.

## 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)*

1. 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.
2. A badge is awarded **at most once** per member, enforced by a unique constraint.
3. Challenges are time-boxed. Progress outside the window does not count, and the window is visible
   to the member.
4. Progress is stored as `current` and `target` so the fraction can be rendered directly. Storing
   only a raw count would make every client recompute it.
5. `initial_credit` is applied at enrolment and is included in `current` — the member sees progress
   before doing anything.
6. Completion is evaluated incrementally on each qualifying event, never by a nightly sweep. A
   member who completes a challenge must see it immediately.
7. Completion emits once and awards its reward through the existing effect executor. Re-evaluating
   a completed challenge is a no-op.
8. Criteria use the segment DSL. Introducing a second condition language here is FORBIDDEN.

## Data *(normative)*

| Table                        | Key invariants                                                                                                                   |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `loyalty.badges`             | `program_id`; `code` unique per program; `criteria` JSONB (segment DSL); `asset_ref`; `is_active`                                |
| `loyalty.badge_awards`       | unique (`badge_id`, `contact_id`); `awarded_at`; append-only                                                                     |
| `loyalty.challenges`         | `program_id`; window `starts_at`/`ends_at`; `criteria` JSONB; `target` integer; `initial_credit` default 0; `reward_id` nullable |
| `loyalty.challenge_progress` | unique (`challenge_id`, `contact_id`); `current`, `target`, `completed_at` nullable; `current` never exceeds `target`            |

## API *(normative)*

| Endpoint                                  | Class      | Permission                                  | Budget       |
| ----------------------------------------- | ---------- | ------------------------------------------- | ------------ |
| `GET/POST/PATCH /v1/loyalty/badges`       | Management | `loyalty.badges.{read\|create\|update}`     | p95 \<1 s    |
| `GET/POST/PATCH /v1/loyalty/challenges`   | Management | `loyalty.challenges.{read\|create\|update}` | p95 \<1 s    |
| `GET /v1/loyalty/members/me/achievements` | Runtime    | member token                                | p95 \<150 ms |

## 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)*

1. A badge is awarded at most once even under 50 concurrent qualifying events.
2. Progress on a challenge with `initial_credit = 2` and `target = 10` starts at 2 of 10.
3. Progress outside the challenge window does not increment.
4. Completion emits once; re-evaluating afterwards emits nothing and awards nothing.
5. `current` can never exceed `target` — proven by the check-constraint violation test.
6. `members/me/achievements` p95 \<150 ms with 50 badges and 10 active challenges.
7. **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`](/design/open-questions-v1), v1.1) and folded
into the normative sections above.

## Changelog

| Version | Date       | Change                                                                     | Why                                      | Author                 |
| ------- | ---------- | -------------------------------------------------------------------------- | ---------------------------------------- | ---------------------- |
| 0.2.0   | 2026-08-17 | Open questions resolved (OQ-LOY-\*) and folded into the normative sections | Owner answered the consolidated register | daniel + claude-opus-5 |
| 0.1.0   | 2026-08-17 | Initial draft                                                              | —                                        | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
