# Stage 21 — `user_name` + `display_name` refactor ## Context The Game Lobby plan moved every in-game `race_name` value into the Lobby Race Name Directory. User Service stopped owning any in-game naming concept. The legacy single-valued `RaceName` field on `UserAccount`, the canonical race-name reservation store, and the `RaceNamePolicy` port were deleted. Two stable fields replace them: - `user_name` — immutable auto-generated `player-` handle, unique platform-wide, assigned once at account creation; - `display_name` — mutable optional free-text label validated by `pkg/util/string.go:ValidateTypeName`, empty by default, not unique. ## Key decisions ### Crockford Base32 lowercase alphabet for the suffix `user_name` ends with eight characters drawn from the alphabet `0123456789abcdefghjkmnpqrstvwxyz` (Crockford Base32 lowercase, `i`, `l`, `o`, `u` excluded). Each `player-` identifier therefore has 40 bits of entropy and is free of visually ambiguous pairs. The generator lives in `user/internal/adapters/local/id_generator.go:randomSuffix`; implementation reads five random bytes via `crypto/rand` and walks the 5-bit groups through the alphabet without using `encoding/base32` so the alphabet swap stays self-contained. ### Retry limit bumped to 10 `authdirectory.Ensurer.ensureCreateRetryLimit` moved from `8` to `10`. Collisions on the 40-bit suffix are expected to be extremely rare; the extra two attempts give a comfortable margin before the service falls back to `503 service_unavailable`. ### Canonical reservation removed `RaceNameReservation`, `RaceNameCanonicalKey`, `ErrRaceNameConflict`, and the `reservation:race-name:*` Redis keys are gone. Uniqueness now comes from the single `lookup:user-name:` index; no canonical form is persisted in User Service. Any existing Redis dataset must be re-initialized (the codebase has no production deployment, so no migration script ships). ### Confusable policy moved to Lobby `user/internal/ports/race_name_policy.go` and `user/internal/adapters/local/race_name_policy.go` were deleted. The Unicode case-fold + digit-to-letter anti-fraud map + TR39 confusable skeleton landed under `lobby/internal/domain/racename/` so Stage 09R can wire it into the new Race Name Directory. Golden fixtures (`Pilot Nova` vs `P1lot N0va`, unicode `paypal` vs Cyrillic lookalike) moved with the policy. ### Admin surface - `/api/v1/internal/user-lookups/by-race-name` is replaced by `/api/v1/internal/user-lookups/by-user-name` (exact 1:1 lookup). - Admin listing `GET /api/v1/internal/users` gains `user_name`, `display_name`, and `display_name_match` query parameters. `display_name` supports `exact` (default) and `prefix` matching. ### Empty `display_name` An empty or whitespace-only input value on `POST /profile` is accepted and stored as an empty `display_name`. Non-empty values are validated by `pkg/util/string.go:ValidateTypeName`; internal whitespace, leading/trailing special characters, and unsupported characters are rejected. ### Eligibility snapshot gains `max_registered_race_names` `lobbyeligibility.limitCatalog` now covers every tariff explicitly. The defaults for `max_registered_race_names` are `free=1`, `paid_monthly=2`, `paid_yearly=6`, `paid_lifetime=0` (unlimited marker). A `LimitCodeMaxRegisteredRaceNames` user-specific override, when active, replaces the tariff default. This feeds Stage 17A's registration quota enforcement and Stage 22's `permanent_block` cascading. ### Domain event payload `ports.ProfileChangedEvent.RaceName` was replaced by `UserName` and `DisplayName`. The Redis stream publisher emits `user_name` on every event and `display_name` only when non-empty, keeping the event type `user.profile.changed` stable. ### Telemetry rename `Runtime.RecordRaceNameReservationConflict` is now `Runtime.RecordUserNameConflict`; the metric name changed from `user.race_name.reservation_conflicts` to `user.user_name.conflicts`. ### Gateway boundary The cross-module FlatBuffers boundary also moved: `pkg/schema/fbs/user.fbs` swaps `AccountView.race_name` for `user_name` + `display_name`, and `UpdateMyProfileRequest.race_name` for `display_name`. `pkg/transcoder/user.go`, `pkg/model/user`, gateway downstream tests, and the integration harness mirror the change. ## Files of interest - Domain: `user/internal/domain/common/types.go`, `user/internal/domain/account/model.go`, `user/internal/domain/policy/model.go` - Storage: `user/internal/adapters/redisstate/keyspace.go`, `user/internal/adapters/redis/userstore/store.go` - Services: `user/internal/service/{authdirectory,selfservice,adminusers,accountview,lobbyeligibility,shared}/` - HTTP + OpenAPI: `user/internal/api/internalhttp/`, `user/openapi.yaml` - Lobby seed: `lobby/internal/domain/racename/{policy,types,policy_test}.go` - Gateway boundary: `pkg/schema/fbs/user.fbs`, `pkg/transcoder/user.go`, `pkg/model/user/user.go`