Skip to content

Give a profile a real unique handle, and drop the slug - #120

Merged
davidtaing merged 2 commits into
mainfrom
profile-handles
Aug 22, 2026
Merged

Give a profile a real unique handle, and drop the slug#120
davidtaing merged 2 commits into
mainfrom
profile-handles

Conversation

@davidtaing

Copy link
Copy Markdown
Collaborator

Closes #119.

What changed

handle is a not null unique column on practitioners, generated by a Postgres default. /p/<handle> is the whole URL — the slug, its generation and the route's canonical redirect are gone.

The defect it fixes: the identifier was id.slice(0, 6), computed in TypeScript and enforced by nothing, and findByHandle resolved with .find(). A collision therefore did not error — it served the wrong practitioner's profile, with their credentials and their badge on it, which on this product is the worst failure available because it fails open and looks fine. The eight seeded profiles collided 100% of the time, so every View profile link on a freshly reset directory landed on Mara Ellison.

The generator, and how it was tested

public.new_profile_handle() returns eight characters of Crockford base32, lowercase — 40 bits over an alphabet excluding i, l, o and u so a handle read aloud or copied off a screen is unambiguous.

Postgres encode() has no base32base64, hex and escape only — so the packing is written out: five bytes are read with get_byte into one 40-bit bigint, then eight five-bit groups are taken high bits first, shifting 35 down to 0. Every one of the 40 bits is consumed exactly once. The randomness is gen_random_uuid() rather than gen_random_bytes(): both are strong, but pgcrypto lives in the extensions schema and this function runs set search_path = '', so it would have to name a schema the platform chose. Bytes 0–4 of a v4 uuid are all random (the version nibble is in byte 6, the variant bits in byte 8), so the first five give 40 unbiased bits.

tests/db/profile-handles.test.ts samples 2,000 handles and asserts four things, because an off-by-one here gives a short or biased handle that looks fine until the collision rate is wrong:

  • every handle is exactly eight characters — catches a shift that runs off the end and contributes nothing;
  • every character is in the alphabet;
  • every symbol appears at every position — catches a shift that is too wide, e.g. >> 36 at slot 0 reaching only 16 symbols. At 2,000 samples the chance of a symbol missing by luck is about e^-62;
  • adjacent positions have independent bits — the test the one above cannot do. A shift overlapping by one (34 instead of 35) still reaches all 32 symbols at every position while the handle carries 36 bits pretending to be 40. Overlapping four bits leaves 2^6 = 64 possible adjacent pairs; independent positions have 2^10 = 1,024, of which 2,000 draws reach around 880. The assertion is > 500, far outside both.

I also checked uniformity directly against the local stack before writing the migration: 64,000 handles, all 256 (position, symbol) pairs present, counts 1,879–2,096 against an expected 2,000 — inside ±3σ (σ ≈ 44) — and the distinct symbol set was exactly the Crockford alphabet.

40 bits is 1.1e12 values: a collision is about one in a hundred million at a thousand profiles. There is no retry loop, because a column default is not the place for one; unique raises 23505 and the insert fails, which is the whole improvement over the old scheme's answer of silently serving somebody else.

Not practitioner-writable, by both mechanisms

handle is absent from every insert and update grant authenticated holds, and practitioners_guard pins it to OLD for non-privileged callers — the same pairing verified and status use, for the reason AGENTS.md gives: a policy has no OLD, so "this row is yours to update, but this column must not change" is unsayable in row level security. The guard is replaced whole (a plpgsql body has no other shape); the only change is one assignment.

The backstop test does what the brief asked: it re-grants update (handle) to authenticated by hand, confirms the write is now accepted at the privilege layer and still pinned to the old value by the trigger, then revokes.

One gap worth stating plainly rather than closing here: there is no before insert guard on practitioners, so on the way in the grant list is the only mechanism — exactly as it is the only mechanism for status today. A migration granting insert (handle) to authenticated would reopen handle choice with no trigger to catch it. That is a property of the existing design rather than something this change introduces, and it is now written down in the spec's trigger section so a future insert guard is a decision rather than a discovery.

anon is granted select (handle) by name — without it the directory cannot build a link and the failure reads as a broken policy rather than a missing grant. authenticated reads it too, so a signed-in practitioner can see their own URL. bluehex_admin can correct a handle, which is the only write path there is and has a test of its own.

practitioners_handle_format states the shape as a check constraint. That is not belt and braces on the generator, which cannot produce anything else — it constrains a literal: the seed's eight, an admin's correction, a vanity handle somebody adds later. It is what holds /p/anthropic-official shut with no reserved-word list for anyone to maintain and eventually get wrong.

Migration safety

not null unique in one migration with no backfill, because the hosted project has zero practitioner rows. The migration's comment says so, and says that a populated table needs the add-backfill-constrain dance instead — so a future reader finding the short version does not conclude the shortcut is generally available.

No existing migration is modified. src/lib/database.types.ts was regenerated with pnpm db:types.

What went with the slug

src/app/p/_lib/handles.ts is deleted, not reduced to a wrapper. Everything it did — strip the slug, read every published id, match six characters in memory, .find() the first hit — existed because Postgres has no prefix match on uuid. With a column the resolution is one .eq("handle", …), which belongs in @/lib/directory beside every other read; findByHandle would have been a second name for getProfileByHandle. That is the AGENTS.md rule about revisiting a requirement when its premise is removed, rather than keeping machinery that satisfies it.

isProfileHandle is added beside profilePath in @/lib/practitioners — a pure predicate over untrusted URL text, so a garbage path costs no round trip. It is explicitly not the enforcement, and the direction it can disagree with the database is safe: a handle it rejects and Postgres would have accepted is a 404, never a wrong profile served.

The seed

Literal handles seed0001seed0008, tracking the last digit of each profile's uuid, so /p/seed0001 is Mara and nothing has to be looked up to know it. Taking the default would give a different URL on every reset, which is the same objection that makes the ids literal. The profile uuids are unchangedtests/db/practitioner-seed.test.ts pins them, and the point of this ticket is that the handle stops being derived from them.

They are deliberately not name-derived: Crockford excludes i, l, o and u, and Mara is the only one of the eight whose name survives that. (The same alphabet refused bluehex0 in a test I wrote by hand — l and u — which is the constraint doing its job.)

Verified by driving the pages, not by reasoning

Against a clean pnpm db:reset and a production build, every row on the directory and the profile it reaches:

row on the directory   link            page served
  OK  Devon Achebe       /p/seed0003    Devon Achebe
  OK  Hollis Fenn        /p/seed0005    Hollis Fenn
  OK  Mara Ellison       /p/seed0001    Mara Ellison
  OK  Priya Raghavan     /p/seed0004    Priya Raghavan
  OK  Toby Nakamura      /p/seed0002    Toby Nakamura
mismatches: 0

And the negatives: /p/seed0006 (pending), /p/seed0007 (rejected) and /p/seed0008 (withdrawn) all 404 — the policy is the filter, since anon cannot read status. So do /p/mara-ellison-222222, /p/222222, /p/mara-ellison-seed0001, /p/SEED0001, /p/seed000i and /p/seed001: the old scheme is gone rather than redirected, and the format is exact.

Verification

Each run separately, real exit codes:

command exit
pnpm lint 0
pnpm test 0 (234 tests)
pnpm build 0
pnpm build, both NEXT_PUBLIC_SUPABASE_* unset 0
pnpm test:db 0 (201 tests)
pnpm test:e2e 0 (20 tests)
pnpm db:reset ×2 0, 0

A profile's public identifier was `id.slice(0, 6)` — the first six characters of its uuid, computed in TypeScript and enforced by nothing. `findByHandle` resolved with `.find()`, which returns the first match, so a collision did not error: it served the wrong practitioner's profile, with their credentials and their badge on it. On the one product whose value is that the badge means something, that is the worst available failure, because it fails open and looks fine. The eight rows in `supabase/seed.sql` collide 100% of the time, because their literal uuids are all prefix — every *View profile* link on a freshly reset directory landed on Mara Ellison, which is what surfaced this.

`handle` is now a `not null unique` column on `practitioners`, generated by `public.new_profile_handle()` as a column default so that every insert gets one whether it came from the app, a migration, the seed or a `psql` prompt. The value is eight characters of Crockford base32, lowercase — 40 bits from an alphabet excluding `i`, `l`, `o` and `u`, so a handle read aloud or copied off a screen is unambiguous. Postgres `encode()` supports `base64`, `hex` and `escape` and not `base32`, so the five random bytes are packed into eight five-bit groups by hand; `tests/db/profile-handles.test.ts` samples 2,000 handles and checks length, alphabet, per-position symbol coverage and the independence of adjacent positions, which is what catches an off-by-one in a shift rather than leaving it to show up as a wrong collision rate. `practitioners_handle_format` states the shape as a check constraint, which constrains a literal rather than the generator and is what holds `/p/anthropic-official` shut with no reserved-word list for anyone to maintain.

Not practitioner-writable, by the same two mechanisms that protect `verified` and `status`: absent from every `insert` and `update` grant `authenticated` holds, and pinned to `OLD` by `practitioners_guard`, which is replaced here to add the one line. Neither is sufficient alone — a policy has no `OLD` — and the backstop test re-grants the column by hand and confirms the trigger still refuses the write. `anon` is granted `select (handle)` by name, without which the directory cannot build a link and the failure reads as a broken policy rather than a missing grant.

`not null unique` in one migration with no backfill, because the hosted project has zero practitioner rows. That is stated in the migration's comment so a future reader does not conclude the add-backfill-constrain dance is unnecessary in general.

The slug is removed rather than made optional: `/p/<handle>` and nothing else. `profilePath` no longer reads the name, the route no longer serves a canonical redirect because there is no second spelling to be canonical against, and `_lib/handles.ts` is deleted — its work was stripping the slug and matching six characters across every published id in memory, all of which existed because Postgres could not filter on the old identifier. One `.eq("handle", …)` replaces it, in `@/lib/directory` beside every other read. Dropping the slug is the forward-compatible direction, which is why it was safe to take now: the handle is the key either way, so a readable prefix can be added later with every published link still resolving, while removing slugs later would break every one of them. What it costs is that a bare URL tells a hiring manager nothing before they click and gives search engines no name signal.

The eight seeded profiles carry literal handles `seed0001` … `seed0008`, tracking the last digit of their uuids, for the same reason their ids are literal: a fixture nobody can link to is not much of a fixture. They are deliberately not derived from the names — Crockford has no `i`, `l`, `o` or `u`, and Mara is the only one of the eight whose name survives that. The profile uuids are unchanged, since the whole point is that the handle stops being derived from them.

Practitioner-chosen handles are out of scope and no hook is left for them. It is the nicest UX and it is user-controlled text on a product selling trust; Bluehex owns the namespace, the same shape as the credential catalogue.

Closes #119.

@davidtaing davidtaing left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review — pass run by Codex (via the codex:codex-rescue subagent), verified and posted by Claude Code (Opus 5). Findings were checked against the source before posting.

Approve with one docs fix. Codex found no defect in the bit packing, the grants and policies, the practitioners_guard replacement, the migration, the application read path, or the tests. I independently diffed the replaced guard body against 20260819194255_profile_core.sql — identical apart from create or replace and the single added assignment.

0 major, 1 minor, 0 nits.

Next: mark the two superseded passages in docs/spec/profile-and-credentials.md (lines 338–344 and 744) as closed by #119, the way prototype/directory/NOTES.md already is in this PR.

Comment thread docs/spec/profile-and-credentials.md
`docs/spec/profile-and-credentials.md` is binding rather than descriptive — AGENTS.md lists it under "Where the schema is decided" — and it settled the identifier ~430 lines below two passages still saying the decision was deferred. Somebody reading top-down stopped at an open gate and could reasonably conclude profile identity was theirs to decide, which is how a second identifier scheme gets proposed against a spec that appears to invite one. That failure is already recorded in this repository once, when a prototype hashed the name into a short id and disagreed with production the moment both existed.

Both passages are marked superseded rather than rewritten, matching how `src/app/prototype/directory/NOTES.md` handles the same problem: the reasoning is still worth reading and only the status was wrong. The deferral was correct, and it is what stopped a slug scheme being invented before anything needed one.

The `Gate:` line gets its own resolution, because a gate with no answer recorded beneath it is the part that actually invites the wrong work. Worth stating what the answer turned out to be: the gate asked for a stable identifier that is not the display name, and the intermediate scheme satisfied that wording and still failed. Six characters of the row's uuid are not the display name and are not unique either, and a stable identifier has to be a unique one — which is a promise only the schema can make.
@davidtaing
davidtaing marked this pull request as ready for review August 22, 2026 04:37
@davidtaing

Copy link
Copy Markdown
Collaborator Author

Review resolved

One thread, fixed in d53dbaf. Nothing deferred, nothing rejected.

Fixed — the spec contradicted itself about whether profile identity was still an open question. docs/spec/profile-and-credentials.md settled the identifier at line 770 while two passages ~430 lines above it still described the decision as deferred: the No slugs, and no profile identity problem section with its unresolved Gate: line, and the note at 744 saying a public profile page "would force the slug decision". That gate was crossed twice — #118 opened the route, this PR settles what is behind it — and the file is binding rather than descriptive, so a reader going top-down could stop at an open gate and conclude the scheme was theirs to choose. Both are now marked superseded rather than rewritten, matching how prototype/directory/NOTES.md is handled in this PR: the reasoning is still worth reading and only the status was wrong.

The Gate: line gets its own resolution, and it is worth recording that the gate's wording was not quite sufficient. It asked for "a stable identifier that is not the display name" — and the intermediate scheme met that literally and still failed, because six characters of a uuid are not the display name and are not unique either. A stable identifier has to be a unique one, which is a promise only the schema can make.

What the review did not find, since a clean pass is evidence too: Codex checked the bit packing, the grants and policies, the practitioners_guard replacement, migration safety, the application read path and the tests, and raised nothing on any of them. I separately diffed the replaced guard body against 20260819194255_profile_core.sql — identical apart from create or replace and the single added assignment.

Verification on d53dbaf

Each command run separately, real exit codes:

command exit
pnpm lint 0
pnpm test 0 234 tests
pnpm build 0
pnpm build, both NEXT_PUBLIC_SUPABASE_* unset 0 the protected property still holds
pnpm test:db 0 201 tests, 12 files
pnpm test:e2e 0 20 tests, desktop + mobile Chromium
pnpm db:reset 0 clean
pnpm db:reset again 0 clean; 8 profiles, 8 distinct handles

Still a draft, and deliberately so — marking it ready is the author's call.

@davidtaing
davidtaing merged commit ff70d5e into main Aug 22, 2026
4 checks passed
@davidtaing
davidtaing deleted the profile-handles branch August 22, 2026 04:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Give a profile a real unique handle, and drop the slug

1 participant