Decide how awards should handle opted-out and non-active users

Everyone can contribute. Help move this issue forward while earning points, leveling up and collecting rewards.

Note: This is a detailed issue by design. The goal is to establish a thorough understanding of the current state, prior decisions, and what still needs to be agreed upon before implementation work is scoped for opted-out and non-active users.

Context

This issue was opened following a Slack discussion where it became clear that alignment is needed on how the award system should handle users who have opted out of awards or are in a non-active state. The outcome will directly influence the open implementation tasks listed in the February 2026 GA roadmap note on the parent epic.

The achievementsAward GraphQL mutation is a public API surface: while this discussion is prompted by three known call sites (the award UI within the monolith, the contributor platform, and the Achievement Automation), any third party can call the mutation directly, which means the behavior decided here applies to all current and future callers.

It is not a bug report and does not propose a solution. It collects verified facts about the current award path and poses the open questions that need alignment before implementation issues can be scoped.

/cc @leetickett-gitlab @Taucher2003

Current award path

On GitLab.com, awards are initiated through three known call sites: the award UI (which calls the achievementsAward GraphQL mutation via the frontend), the contributor platform, and the Achievement Automation, both of which call the mutation directly via the GraphQL API. As noted above, the mutation is a public API surface and these three are not the only possible callers. All paths reach AwardService#execute.

The award flow in app/services/achievements/award_service.rb calls User.find(recipient_id) and immediately creates a UserAchievement record. No check on recipient user state occurs before the write.

The only user-state guard in the award path is in the mailer: app/mailers/emails/profile.rb guards new_achievement_email with return unless user&.active?. This fires after the record is already created.

User#active? is generated by the state_machine gem. It returns true only when state == 'active'. Blocked, banned, ldap_blocked, and deactivated users all have active? == false.

achievements_enabled is a boolean on user_preferences (default true), delegated to User. In the current live code it controls whether achievements are displayed on the user's profile, gated in GroupPolicy and the profile sidebar. It is not checked anywhere in the award path.

Impact by user state

The table below shows what the current code does for each recipient user state across four dimensions.

  • "Visible in award UI autocomplete" means the user appears as a selectable recipient in the award UI when someone attempts to award them.
  • "Award record created (GraphQL)" reflects what happens when the achievementsAward mutation is called directly, bypassing the UI.
  • "Counted in X awarded users" refers to the live count displayed on the group achievements page.
Scenario Visible in award UI autocomplete Award record created (GraphQL) Email sent Counted in "X awarded users"
Active, opted in Yes Yes Yes Yes
Active, opted out (achievements_enabled: false) Yes Yes Yes (opt-out not checked in mailer or notification service) Yes
Deactivated Yes Yes No (active? is false) Yes
Blocked / banned No (excluded by without_forbidden_states in UsersFinder) Yes (AwardService uses plain User.find, no state check) No (active? is false) Yes

The "X awarded users" count is a live COUNT DISTINCT query backed by Achievement#unique_users (users.distinct via the user_achievements join). No user state filter is applied. The count is displayed in achievements_app.vue and also gates whether the awarded-user avatar list renders at all (v-if count > 0).

Points for discussion

  1. The achievements_enabled opt-out flag is not checked anywhere in the award path (service, mutation, or mailer). An active user who has opted out receives both an award record and an email.
  2. The award UI autocomplete excludes blocked and banned users via without_forbidden_states in UsersFinder, but the achievementsAward GraphQL mutation calls User.find with no state filter. Both surfaces can be used to attempt awarding the same user.
  3. The Achievement#unique_users count has no user state filter. Award records for deactivated or blocked users are included in the "X awarded users" display and in the v-if gate that determines whether the avatar list renders.
  4. There is no cleanup or adjustment of user_achievements records when a user is blocked or deactivated. The project star count has an after_transition active: any hook that adjusts counts immediately on state change. No equivalent exists for achievements.

Existing precedents in the monolith

These three examples show how GitLab handles analogous situations today.

User#following_users_allowed? (CE)

File: app/models/user.rb Before a follow action is created, both the actor's and the target's user_preference.enabled_following are checked. If the target has following disabled, the follow is blocked regardless of account state. This is a user-preference boolean that makes a user ineligible to be targeted by a specific social action, enforced at the model/service layer. The follower autocomplete does not filter by this preference; the gate is on the action itself.

Call-site behavior: when enabled_following is false on the target, User#follow returns bare false. The REST API (lib/api/users.rb) calls not_modified! unless followee and returns HTTP 304 Not Modified. There is no GraphQL mutation for following. The call site cannot distinguish "following disabled" from "already following": both return 304. This is a silent no-op, no error is surfaced.

Project and CI/CD catalog star counts

Files: app/models/users_star_project.rb, app/models/user.rb (state machine transitions) UsersStarProject only increments star_count when user.active?. The User state machine fires after_transition active: any to decrement star counts on all of the user's starred projects when they are blocked, deactivated, or banned. after_transition any => :active re-increments when a user returns to active. The CI/CD catalog delegates star_count to its project and inherits this behavior. Achievement counts currently have no equivalent mechanism. This precedent concerns count accuracy, not action eligibility, so there is no call-site error dimension.

EE SAML-enforced invite filter

File: ee/app/finders/ee/members/invite_users_finder.rb When a group has enforced SSO, the invite autocomplete narrows to users who have a linked SAML identity for that group's provider. This is a per-user profile property that gates who appears in the select list within a specific group context. It is enforced at both the finder (autocomplete) and service layers.

Call-site behavior (EE only): when a caller bypasses the autocomplete and POSTs directly to add a member who fails the SAML check, the check fires as a model validation (validate :sso_enforcement in ee/app/models/ee/group_member.rb). The member record is invalid, and the REST API returns HTTP 400 with the SAML error message. The call site unambiguously receives an error.

Prior decisions

!228084 proposed repurposing achievements_enabled to mean "allow others to award me achievements": a forward-looking opt-out enforced at the service layer, returning an error if recipient.achievements_enabled == false. This is also the open engineering intent recorded in #593740. During review, @Taucher2003 raised the concern that existing opted-out users' awards would become visible after the change. The agreed resolution was a data migration (#596772) to backfill show_on_profile to false for those users. The scope of achievements_enabled: false as a forward-looking award opt-out was confirmed by both parties. The MR has not yet merged due to sequencing: #596772 must land first, followed by !227918 (merged), then !228084.

Open questions

  1. Opted-out users (achievements_enabled: false): how should the award path handle a user who has explicitly opted out? Should an award attempt be blocked at the service layer (error returned, no record created), or is creating a record acceptable as long as no email is sent? Today, the email is also sent for active opted-out users because achievements_enabled is not checked in the mailer or notification service.
  2. Blocked and deactivated users: should these users be treated the same way as opted-out users by the award path, or is there a meaningful distinction between "I don't want awards" (a deliberate user preference) and "this account is in a non-active administrative state"? Today the behavior is the same for both: the UI autocomplete already excludes blocked users via without_forbidden_states, but the GraphQL mutation creates an award record with no state check and no email for either.
  3. Consistency across surfaces: whichever rules are decided in questions 1 and 2, should they apply consistently at both the award UI autocomplete and the achievementsAward GraphQL mutation? Today blocked users are excluded from the autocomplete but can still be awarded via the mutation. Should both surfaces enforce the same rules for who can receive an award? If yes, where is the right enforcement layer: the finder, the service, the policy, or all three?
  4. The count: the "X awarded users" display includes records for deactivated and blocked users. For starred projects, GitLab uses state-machine transition hooks to keep the count accurate in real time. Should achievement counts adopt a similar mechanism, or is a read-time filter on Achievement#unique_users sufficient?
  5. Call-site responsibility: if the monolith adds checks based on the decisions above, should AwardService surface a structured error to the call site, or silently no-op? The two precedents show both patterns exist: User#follow returns a silent HTTP 304, while the SAML invite check returns HTTP 400 with an error message. Separately, should the caller (for example, the contributor platform automation) be expected to check recipient state before calling, or should the monolith enforce it and return a clear error that the caller must handle? If the monolith returns a clear error, how should the caller treat it: as a permanent failure (stop retrying for this user) or as transient (retry on next run)?

Proposed next step

Once there is alignment on the questions above, the expected outputs are:

  1. Implementation: the decisions made here will be translated into implementation work.
  2. API and developer documentation: the achievementsAward mutation is a public GraphQL API surface. Callers beyond the three known ones (award UI, contributor platform, and Achievement Automation) exist or will exist. The decisions made here must be documented: what the mutation accepts, what errors it can return for each user state, and how callers should handle those errors. #388390 (closed) covers user-facing documentation, which is complete. API and caller documentation is a separate area with no existing issue.

Three existing child items of the parent work item are related. They provide history relevant to the open questions in this issue.

  • #593740 : "Achievements: Add global opt-out of achievements" (open):

    proposes repurposing user_preferences.achievements_enabled from a display toggle to an award-eligibility gate. Its description explicitly quotes the agreed engineering intent from the February 2026 GA roadmap: "An explicit opt-out of Achievements should be implemented to stop awards from happening, which allows enterprise user controls."

    The review discussion in that MR, between @mmichaux-ext and @Taucher2003, confirmed the scope: achievements_enabled: false is a forward-looking opt-out that prevents new awards. Handling of existing awards for opted-out users is addressed separately via the data migration in #596772.

  • #578767 : "Consider hiding or allow toggling inactive users" (open):

    tracks filtering blocked and banned users from achievement award lists and the achievements page UI. Spawned from a real case where a blocked user appeared in the achievement section. No prior decision recorded.

  • #425218 (closed) : "Deleted users that awarded achievements cause invalid UserAchievements" (closed December 2023):

    covered deleted awarders causing invalid UserAchievement records. Fixed by making awarded_by_user optional (!137992 (merged)). The ruling covered only the awarder side. No decision was made about blocked or deactivated recipients.

Edited by 🤖 GitLab Bot 🤖