M MemberIntel KB
Activity Decisions

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.

  1. The achievements table is the source of truth for “has earned” — one row per
    (user_id, achievement_key), unique on that pair, carrying earned_at.
  2. The rules module only ever inserts. There is no revocation path, no reconciliation
    sweep, and no DELETE. Deleting a site does not un-earn Connected.
  3. 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 in CONTEXT.md.
  4. Multi-level achievements store one row per level (advisor_5, advisor_10,
    advisor_50) rather than mutating a level column, so each row stays an atomic
    immutable fact with its own earned_at. Surfaces render the highest level reached as a
    single badge.
  5. 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_at is 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 later earned_at than the true
    moment. Accepted as a minor fidelity loss.
For: S Seth Shoultes A AI Engineer B Blair Williams S Santiago Perez Asis P Product Lead