decision
ADR-0039: Earned achievements are append-only and never revoked
ADR-0039 (Accepted, 2026-07-29): Earned achievements are append-only and never revoked.
Status: Accepted
Date: 2026-07-29 (accepted 2026-07-30)
Deciders: Ronaldo Reymundo (spec author); Seth Shoultes signed off in memberintel#848; scoped from memberintel#732 (brainstormed with Seth)
Context
memberintel#732 adds mild gamification — a handful of one-time achievement badges to mark
activation and engagement. Every badge is derivable from data that already exists, with no
new event tracking: User.onboarding_completed_at, Site.install_uuid / plugin_version,
Site.user_id, and Conversation.user_id.
Because eligibility is derived, it can lapse. Two of the five badges sit on data the
operator can remove: DELETE /api/v1/sites/{site_id} and disconnect_from_plugin
(src/memberintel/api/sites/router.py:727, :775) both exist, so an operator who connects
a site, earns Connected and Plugged In, then deletes that site no longer satisfies either
condition. The engagement badges are stable by comparison — conversations have no delete
endpoint, and the retention sweep only prunes message_tool_results, never conversations
or messages (src/memberintel/api/chat/retention_router.py).
So the project had to decide what an earned badge is: a live assertion about present
state, or a permanent record that something once happened.
Forces: (1) the issue is explicit that this is a B2B advisor tool and badges are “gentle
progress markers”, ruling out manipulative mechanics; (2) taking a badge away is a
worse experience than never granting it; (3) whatever we choose has to serve two surfaces
(the member app and the plugin’s wp-admin screens) reading the same source of truth.
Decision
An achievement is a permanent record, not a live assertion.
- The
achievementstable is the source of truth for “has earned” — one row per
(user_id, achievement_key), unique on that pair, carryingearned_at. - The rules module only ever inserts. There is no revocation path, no reconciliation
sweep, and noDELETE. Deleting a site does not un-earn Connected. - Existing data is an input to earning, evaluated forward only. “Eligibility” (a live
condition over existing data) and “earned” (a recorded permanent fact) are separate
concepts and are named separately inCONTEXT.md. - Multi-level achievements store one row per level (
advisor_5,advisor_10,
advisor_50) rather than mutating alevelcolumn, so each row stays an atomic
immutable fact with its ownearned_at. Surfaces render the highest level reached as a
single badge. - Announcing an earned achievement is likewise recorded, not derived: a
celebrated_at
column stamped by whichever surface shows the toast, so the moment fires exactly once
across app, wp-admin, and every device. This follows the existing “notified once” marker
convention (User.nudge_sent_at,Site.sync_stalled_notified_at).
Considered options
- Derive on read, store nothing. Simplest schema, no migration, no backfill. Rejected:
badges vanish when a site is deleted, so “You earned it!” followed by silent un-earning
reads as a bug, and the celebration has nowhere durable to record that it fired. - Store, then reconcile. Rows stored, with a sweep revoking rows whose eligibility
lapsed. Rejected: it buys truthfulness about present state that this feature does not
need, at the cost of revocation logic, another scheduled job, and a product decision
about whether losing a badge notifies the operator.
Consequences
Positive:
- The read path is a plain table query; no rules evaluation is needed to answer “what has
this operator earned?” - Badges are monotonic, which is what “gentle progress marker” requires.
- Because rows are permanent and insert-only, backfilling existing operators needs no
separate code path — the same evaluation that serves a live read also backfills.
Negative / costs:
- A badge can contradict live state: an operator with no connected sites still shows
Connected. This is deliberate. Surface copy must read as “you did this”, never as “this
is true now” — and a future reader finding no reconciliation job should read this ADR
rather than assume an oversight. - Under evaluate-on-read (a spec-level choice, not fixed here),
earned_atis the moment
eligibility was first observed, not when it was first met. An operator who connects a
site via the plugin and never opens the app carries a laterearned_atthan the true
moment. Accepted as a minor fidelity loss.