Summary
Every Scout profile carries a unique handle — the name shown on posts,
comments, photos, the leaderboard and the in-app Profile screen, and the thing
@-mentions resolve against. It is not shown on the public profile page
(see below). A handle is derived from the account's email address at signup
(and by the one-time backfill), and since 2026-09-28 a member can change it
on the Edit profile screen (board ticket 1c035f64) — as often as they like, with
the old handle freed at once. Guests keep the generated anon-<animal>-<n> name
they already displayed, promoted into a real handle, and cannot change it.
Before this existed, members had no name at all. Profile.displayName was set
to null on signup and wiped again on guest→member promotion, so what users saw
was a computed fallback — the local part of their email address — which two
different accounts could produce identically.
Status (shipped / beta-badged / flagged off)
Shipped, unconditional. No feature flag gates handles; they are on for every account. There is no opt-out, because the handle replaces a name that was already being displayed. Changing a handle (Edit profile) is also shipped and unflagged, for members only.
User-facing surfaces
-
Every byline — community posts and comments, patch photos, direct-message threads. All flow through
displayNameFor(). -
The leaderboard (
scout://leaderboard), which shows the handle of every non-internal account with at least one patch, including people who have never posted. It also goes throughdisplayNameFor(), viaprofileNameMap. -
Profile screen (
mobile/app/(drawer)/profile.tsx:297, inProfileScreenViewModelImpl) — the large name under "Field Guide · Member" is now the handle, rendered verbatim (lowercase) rather than title-cased, so one person reads as one name on both the profile and their posts. -
The
@-mention picker in the comment composer. -
Edit profile (
mobile/app/edit-profile.tsx,scout://edit-profile, reached from Profile's "Edit profile" button or its avatar) — the only place a handle is changed. See "Changing a handle" below.
How it works (end-to-end)
-
Derivation —
normalizeHandle()(backend/src/profile/handle.ts) takes the email local part (or a guest'sanon-*name) and applies one rule: lowercase · drop the+tag· fold accents viaNFKD· strip anything outside[a-z0-9-]· collapse repeated hyphens · trim leading/trailing hyphens · pad to a 3-character minimum.saxal28+09524576@gmail.com → saxal28 jane.mccarthy@gmail.com → janemccarthy A.Lan_Sax+beta@work.co → alansax josé@gmail.com → jose anon-otter-11 (a guest) → anon-otter-11 (unchanged)Hyphens are permitted specifically so guests need no special case: no email local part contains one after
+tagremoval, so allowing them changes nothing for members while leavinganon-otter-11intact. -
Collision handling —
handleCandidates()returns the bare form, thenname2 … name9, then a candidate derived from the profile id (janemccarthy-abcdef). The id-derived tail makes the list unexhaustible: the local database has 33 plus-addressed variants of one address, all normalizing tosaxal28, and they walk past every numeric suffix. -
Claiming —
pickHandle()(backend/src/profile/handle-assignment.ts) SELECTs which candidates are already taken and returns the first free one. It reads before it writes deliberately: every caller runs inside a Prisma interactive transaction, and in Postgres a constraint violation aborts the whole transaction, so a try/catch retry loop over candidates cannot work there. -
Assignment happens at six auth call sites (
backend/src/auth/auth-engine.service.ts): password signup and promotion, guest creation, OAuth signup and promotion, and magic-link signup. Creates pre-generate their own UUID rather than letting the database default it, because the terminal fallback candidate is derived from the id. -
Promotion re-derives. When a guest becomes a member, the handle is recomputed from the new email rather than merely surviving — otherwise the account would wear
anon-otter-11permanently, since nothing can edit it. -
Rendering —
displayNameFor()(backend/src/patch-photos/username.ts) is handle-first, falling back to the email local part, thendisplayName, then"Guest". The fallback chain is not dead code: refresh tokens are non-rotating, so a client can present an access token minted before handles shipped. -
The JWT carries it — since 2026-09-28.
handleis signed into the access token (auth-engine.service.ts:151,172pass it toAccessTokenService.sign,backend/src/auth/token/access-token.service.ts) and read on the client (mobile/src/lib/auth/claims.tsdecodeAccessClaims). Until 2026-09-28 it was not:sign()dropped the field both call sites passed, anddecodeAccessClaimsnever read it, souser.handlewas always undefined and the Profile screen showed the title-cased email prefix. An earlier version of this doc said the claim was signed; it was wrong. Both halves are now fixed and pinned by regression tests. The claim is optional on the client for the same non-rotating-refresh reason (nullfrom the server,undefinedwhen absent). The app's own identity (handle and photo) is read fromGET /api/account/profile, not the token, because a token cannot carry the photo and its claim is stale until the next refresh after a rename. -
Changing a handle.
PATCH /api/account/profilewith ahandlefield (backend/src/account/profile-edit.service.ts). The chosen-handle rule isvalidateChosenHandle()(backend/src/profile/handle.ts), stricter than the signup derivation — it rejects rather than rewrites:- lowercased and trimmed;
^[a-z0-9]+(-[a-z0-9]+)*$(no leading, trailing or doubled hyphen); 3–20 characters; - reserved:
admin,scout,support,staff,moderator,official,help,system,root, and anything startinganon-(the guest namespace); - profanity: each hyphen-separated segment through
findBlockedTerm, plus the stem list (containsBlockedStem,backend/src/moderation/text-filter.ts) anywhere in the handle. A plain substring check over every term is deliberately NOT done — it would rejecttherapist,grapevineandscunthorpe. Existing handles that break these rules are grandfathered: the rule runs only when the handle actually changes, so a photo-only save never trips on an old 21-character handle. There is no cooldown, and the old handle is free for anyone immediately — no history, no hold. A taken handle returns 409 with up to three free, valid suggestions (thehandleCandidates()ladder filtered through the rule); two concurrent claims resolve to exactly one owner via the unique index (isHandleConflict). After a successful save the app writes the result into itsmyProfilecache, invalidates byline queries, and refreshes the session so the claim is current.
- lowercased and trimmed;
Data model (Prisma)
Profile.handle(backend/prisma/schema.prisma) —String? @unique, mapped tohandle. Nullable + unique, matching the existingslug/referralCodeprecedent, which is also what let the backfill run before anything depended on it.
Distinct from Profile.slug, which is a URL segment that always carries a hash
suffix and is minted lazily on first public-profile view.
API surface
Handles are read through existing payloads (post/comment bylines, profile
responses) and the JWT claim. Since 2026-09-28 (all JwtAuthGuard, members only
for writes — a guest gets 403):
| Method & path | Throttle | Purpose |
|---|---|---|
GET /api/account/profile |
— | The caller's own { id, handle, avatarUrl, email, isAnonymous } |
GET /api/account/handle-availability?handle= |
60/min | { available, handle, reason?, suggestions? } — reason is format, too-short, too-long, reserved or taken |
PATCH /api/account/profile |
10/min | Multipart: optional handle, photo, removePhoto — see profile-photos |
There is still no endpoint to search handles.
Key files
backend/src/profile/handle.ts— the signup derivation (normalizeHandle), the candidate ladder, and the chosen-handle rule (validateChosenHandle,RESERVED_HANDLES,MAX_HANDLE_LENGTH).backend/src/account/profile-edit.service.ts— changing a handle (and photo): availability, suggestions, the save, race handling.backend/src/account/account.controller.ts— the three/api/account/*profile routes and their throttles.backend/src/auth/token/access-token.service.ts—sign(), which now actually writes thehandleclaim.mobile/app/edit-profile.tsx,mobile/src/hooks/useHandleAvailability.ts,mobile/src/api/account.ts,mobile/src/hooks/useMyProfile.ts— the editor, its live check (a local mirror of the format rule, then a debounced server check), the client, and the app's own-identity query.backend/src/profile/handle-assignment.ts—pickHandle, andisHandleConflictfor distinguishing a handle race from a duplicate email.backend/scripts/backfill-handles.ts— the one-time backfill; dry by default,--applyto write.backend/src/auth/auth-engine.service.ts— the six assignment call sites.backend/src/patch-photos/username.ts—displayNameFor, handle-first.backend/src/prisma/profile-names.ts— the shared identity map (profileIdentityMap: name + photo);handleandavatarUrlmust stay in its select or bylines silently fall back.mobile/src/providers/authState.ts,mobile/src/lib/auth/claims.ts— the client-side claim.
Configuration and flags
None. No environment variable or feature flag affects handles.
Edge cases and known limits
- The backfill claims first-come, ordered by
created_at ASC, so the oldest account wins the unsuffixed form rather than whoever the query returns first. - The dry run reserves in-memory. Without that, two accounts deriving the same handle would both be previewed as claiming the bare form, because neither write has happened — a misleading preview for something immutable.
- A handle race returns a distinct error. Two simultaneous signups can pick
the same free candidate; the loser gets a P2002 on
handle, whichisHandleConflictseparates from a duplicate email so the user is not told to recover an account they do not have. - A too-short or all-punctuation source falls back to a padded form or
"scout", never an empty string. - Two accounts can still look similar (
janemccarthy/janemccarthy2); they are unique, not visually distinctive.
What this feature does NOT do
- Guests cannot change their handle. Only members reach Edit profile; the endpoint returns 403 for a guest.
- There is no admin tool to reset a member's handle. A chosen handle that slips past the reserved list and profanity check can only be changed by a manual database edit (or by the member).
- There is no handle history, hold or cooldown. A member may rename as often as they like, and the handle they leave is free for anyone immediately — someone else can take it the next second. Nothing redirects or reserves an old handle.
- Old
@mentionsare not rewritten. Mentions are stored as user ids, so notifications and the link to the person are unaffected by a rename — but the literal@oldnametext already written in comment bodies stays as it was, and can end up naming whoever claims that handle next. - Handles do not hide the email address. They are derived from the email
local part, so
jane.mccarthy@gmail.comis bylinedjanemccarthy. Posting in the community therefore still publishes a recognisable fragment of the address — unchanged from before this feature, not fixed by it. Anyone writing copy about privacy should not claim otherwise. - There is no global handle search. Nothing in the app or API resolves a
handle to a user. The
@-mention picker is limited to thread participants specifically so the user base cannot be enumerated by prefix. - Derived handles are not reserved or moderated, and no handle can be reported. The reserved list and profanity check apply only when a member CHOOSES a handle on Edit profile; a handle derived from an email at signup is not screened, and there is no way to report a handle.
- Handles are not displayed with an
@prefix anywhere except inside mention text. - The public profile page does not show handles either.
GET /u/:slug, its page title and its/u/:slug/card.pngshare card all renderdisplayName?.trim() || 'Field Explorer'— blank counts as missing since 2026-10-13 (public-profile.service.ts:95,public-profile.controller.ts:137,og-card.ts:71) — anddisplayNameis null for nearly every member (146 of 147 in prod when last counted, 2026-09-22; not re-counted since), so nearly every public page reads "Field Explorer". The URL slug is id-derived for the same reason, so the page never exposes the email-derived handle. An earlier version of this doc listed the public profile as a handle surface; it was wrong. - The invite page does not use handles.
backend/src/referral/invite-page.controller.ts:89readsdisplayName, which is null for every member, so it always renders "A friend". Left deliberately: pointing it at a handle would publish part of an email on a page shown to everyone a user invites.
Tests that cover it
backend/src/profile/handle.spec.ts— the format rule, including accent folding (falsified: withoutNFKD,josé→jos), guest-handle preservation, and the candidate ladder's uniqueness.backend/src/profile/__tests__/handle-assignment.db.spec.ts— collision walking against a real database, and the id-derived fallback when every numeric suffix is taken.backend/src/patch-photos/username.spec.ts— handle-first precedence, plus the pre-handle fallback chain that non-rotating refresh keeps alive.backend/src/prisma/profile-names.spec.ts— assertshandleis in the select, which is the guard against the silent no-op.backend/src/auth/auth-engine.service.spec.ts— assignment on signup and re-derivation on promotion, asserting the exact derived handle.mobile/src/providers/__tests__/authState.test.ts— the claim, and tolerance of a token minted before handles existed.mobile/screen-tests/profile.test.tsx— a paired opposite-branch pair: the handle renders verbatim, and a pre-handle session still shows the title-cased email prefix; a long handle wraps to two lines, never truncated.backend/src/auth/token/access-token.service.spec.ts— thehandleclaim is signed (andnull, not omitted, for a profile with none). Regression: proven failing against the oldsign().mobile/src/lib/auth/__tests__/claims.test.ts— the claim is decoded; absent ornullreads as no handle. Regression: proven failing against the old decoder.backend/src/profile/handle.spec.ts(validateChosenHandle) — every rule, the 3/20 boundaries, theanon-prefix, a stem hidden in a handle, a blocked word as a hyphen segment, and the Scunthorpe guard (therapist,scunthorpeaccepted).backend/src/account/profile-edit.service.spec.ts— lowercasing before the taken check, grandfathering, guest 403, suggestions, invalid/taken errors;__tests__/profile-edit.db.spec.ts— a rename frees the old handle at once, and two concurrent claims leave exactly one owner.mobile/screen-tests/edit-profile.test.tsx— taken + suggestions, invalid characters (no request, Save disabled), too short, reserved, a successful save (sends the handle, refreshes the session, updates the cache).
Open questions
The production backfill has not been runResolved 2026-09-27: the prod backfill ran (backfill-handles.ts --applyon the droplet, after a dry run and a freshscout-db-backup.shdump). It assigned the 47 profiles still without a handle (24 guests, 3 test accounts, 20 members); prod now has 102 profiles and 102 distinct handles, every one matching^[a-z0-9-]{3,}$. Only one collision needed a suffix (an owner+tagalias →saxal282).- Sign in with Apple + "Hide My Email" produces random-looking handles. The
relay address's local part is random (
kg9jfhdnwg@privaterelay.appleid.com), so the handle is too. Private, but odd on a byline or the leaderboard; 6 of the 20 members backfilled on prod look like this. Addressed 2026-09-28: members can now rename on Edit profile. Whether the permanent-handle decision should be revisited once profile editing exists.Resolved 2026-09-28 (board ticket 1c035f64): handles are editable, no cooldown, old handle freed at once, existing handles grandfathered.