Summary
Every Scout profile carries a unique, permanent handle — the name shown on
posts, comments, photos and public profiles, and the thing @-mentions resolve
against. Handles are derived from the account's email address by a one-time
backfill and cannot be changed by the user: no editor ships with this
release. Guests keep the generated anon-<animal>-<n> name they already
displayed, promoted into a real handle.
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.
User-facing surfaces
- Every byline — community posts and comments, patch photos, direct-message
threads. All flow through
displayNameFor(). - Profile screen (
mobile/app/(drawer)/profile.tsx:321, 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. - Public profile and its OG share card.
- The
@-mention picker in the comment composer.
There is no surface for changing a handle. See "What this feature does NOT do".
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.
handleis signed into the access token (auth-engine.service.ts:151,172) and read on the client (mobile/src/lib/auth/claims.ts). The claim is optional on both sides for the same non-rotating-refresh reason.
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
None. Handles are read through existing payloads (post/comment bylines, profile responses) and the JWT claim. There is no endpoint to set, change, or search handles.
Key files
backend/src/profile/handle.ts— the format rule and candidate ladder.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 name map;handlemust stay in its select or the whole flip silently no-ops.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
- Users CANNOT change their handle. There is no editor, no endpoint, no settings row. Whatever the backfill assigned is permanent for now. This was a deliberate scope decision — profile editing is a later feature.
- 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. - Handles are not verified, reserved, or moderated. There is no reserved-word list, no profanity filter on them, and no way to report one — they are derived from an email nobody chose for display.
- Handles are not displayed with an
@prefix anywhere except inside mention text. - The invite page does not use handles.
invite-page.controller.ts:42readsdisplayName, 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.
Open questions
- The production backfill has not been run — these numbers are from local
scout_dev(189 profiles, 189 distinct handles). Prod's collision profile, particularly around plus-addressed accounts, is unverified. - Whether the permanent-handle decision should be revisited once profile editing exists; nothing here blocks adding an editor later, but existing handles would need a rename path and a uniqueness re-check.