Give a profile a real unique handle, and drop the slug - #120
Conversation
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
left a comment
There was a problem hiding this comment.
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.
`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.
Review resolvedOne thread, fixed in Fixed — the spec contradicted itself about whether profile identity was still an open question. The What the review did not find, since a clean pass is evidence too: Codex checked the bit packing, the grants and policies, the Verification on
|
| 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.
Closes #119.
What changed
handleis anot null uniquecolumn onpractitioners, 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, andfindByHandleresolved 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 excludingi,l,oanduso a handle read aloud or copied off a screen is unambiguous.Postgres
encode()has nobase32—base64,hexandescapeonly — so the packing is written out: five bytes are read withget_byteinto one 40-bitbigint, 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 isgen_random_uuid()rather thangen_random_bytes(): both are strong, but pgcrypto lives in theextensionsschema and this function runsset 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.tssamples 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:>> 36at slot 0 reaching only 16 symbols. At 2,000 samples the chance of a symbol missing by luck is about e^-62;> 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;
uniqueraises23505and 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
handleis absent from everyinsertandupdategrantauthenticatedholds, andpractitioners_guardpins it toOLDfor non-privileged callers — the same pairingverifiedandstatususe, for the reason AGENTS.md gives: a policy has noOLD, 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)toauthenticatedby 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 insertguard onpractitioners, so on the way in the grant list is the only mechanism — exactly as it is the only mechanism forstatustoday. A migration grantinginsert (handle)toauthenticatedwould 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.anonis grantedselect (handle)by name — without it the directory cannot build a link and the failure reads as a broken policy rather than a missing grant.authenticatedreads it too, so a signed-in practitioner can see their own URL.bluehex_admincan correct a handle, which is the only write path there is and has a test of its own.practitioners_handle_formatstates 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-officialshut with no reserved-word list for anyone to maintain and eventually get wrong.Migration safety
not null uniquein 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.tswas regenerated withpnpm db:types.What went with the slug
src/app/p/_lib/handles.tsis 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 onuuid. With a column the resolution is one.eq("handle", …), which belongs in@/lib/directorybeside every other read;findByHandlewould have been a second name forgetProfileByHandle. That is the AGENTS.md rule about revisiting a requirement when its premise is removed, rather than keeping machinery that satisfies it.isProfileHandleis added besideprofilePathin@/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
seed0001…seed0008, tracking the last digit of each profile's uuid, so/p/seed0001is 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 unchanged —tests/db/practitioner-seed.test.tspins 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,oandu, and Mara is the only one of the eight whose name survives that. (The same alphabet refusedbluehex0in a test I wrote by hand —landu— which is the constraint doing its job.)Verified by driving the pages, not by reasoning
Against a clean
pnpm db:resetand a production build, every row on the directory and the profile it reaches:And the negatives:
/p/seed0006(pending),/p/seed0007(rejected) and/p/seed0008(withdrawn) all 404 — the policy is the filter, sinceanoncannot readstatus. So do/p/mara-ellison-222222,/p/222222,/p/mara-ellison-seed0001,/p/SEED0001,/p/seed000iand/p/seed001: the old scheme is gone rather than redirected, and the format is exact.Verification
Each run separately, real exit codes:
pnpm lintpnpm testpnpm buildpnpm build, bothNEXT_PUBLIC_SUPABASE_*unsetpnpm test:dbpnpm test:e2epnpm db:reset×2