# Design note — badges

**Dated 2026-08-05. Written before the code, per the provenance practice in
[ROADMAP.md](../../ROADMAP.md#proving-independence).**

---

## The job to be done

Points say how much somebody has done. A badge says what they *did* — first
post, a hundred replies, a year here. Points are a number that only means
anything next to somebody else's; a badge means something on its own.

## Decision 1 — a badge is earned, not held

**This is the decision the whole feature turns on.** Once somebody crosses a
threshold, the badge is theirs. Deleting posts afterwards does not take it
back, and neither does the operator raising the threshold later.

The alternative is a badge that is really a live status indicator, and it is
worse in every case:

- Somebody tidying up their own old posts loses "100 posts", which reads as a
  punishment for housekeeping.
- Raising a threshold from 100 to 500 silently strips the badge from everybody
  between, who did nothing wrong.
- It makes every deletion a re-evaluation of every rule for that member, for a
  result nobody wanted.

The honest framing is that a badge records **that something happened**, and
things that happened stay happened.

## Decision 2 — the exception is a moderator, by hand

Earned-and-kept has one real failure: a spammer who posts a hundred things,
collects a badge, and gets cleaned up.

That is a moderation problem, so it gets a moderation answer — a moderator can
revoke a badge from an account. One deliberate action, not a rule that fires on
every delete.

## Decision 3 — a small set of rule types, each with a number

Posts written, topics started, points earned, reactions received, days since
joining. The operator names the badge and sets the number.

The temptation is a rules engine. An engine means an expression language, a
way to test it, and a support burden — and every operator who asks for one
wants one of these five with a different number.

## Decision 4 — evaluated when something could have changed, not on a schedule

Awarding runs on the events that could move a counter: a post, a topic, a
reaction. No sweep, no cron, no queue.

The one that does not fit is **days since joining**, which changes while nobody
is looking. Rather than add a scheduler for it, it is evaluated alongside the
others whenever the member does anything.

That means a dormant member does not collect their "two years" badge until they
come back. That is the right behaviour anyway: the badge appears the moment
they return, which is when anybody is looking at their profile.

## Decision 5 — awarding is idempotent, and the pair is unique

One row per member per badge, with a unique key. Evaluating twice awards once.
The same property the points ledger has, for the same reason.

## Decision 6 — badges are quiet by default

They render on the profile. Showing them beside posts is **opt-in per badge**,
off by default, for the same reason profile fields are: the sidebar is read on
every post of every thread, and a column of badges there costs more attention
than it returns.

## Decision 7 — the rest of the system is told at the same time

Awards are in the **data export** and cleared on **erasure**. Third time this
has been written down; it is the rule now, not a reminder.

## What this does not do

- **No manual awarding.** An operator handing out badges turns a record of what
  happened into a gift, and every omission into a complaint. Revoking exists
  because spam does; granting does not, because favouritism does.
- **No secret or surprise badges.** A badge nobody can see the condition for is
  a puzzle, not a recognition.
- **No badge-based permissions.** Access is what groups are for.
- **No progress bars towards the next badge.** It turns recognition into a
  quest log, which is the failure mode this whole area invites.
