Runbook: Email uniqueness — CHECK + partial unique index (T7)
Production ops for the account-linking / email-identity plan (Rev 5, data-model steps 3–4, rollout step 4). This step follows T6. It does not replace it.What this does
- Assert the database locale is the one JS-parity was derived against, zero stragglers, and zero duplicate non-empty emails (hard gates).
- Add
users_email_normalized_checkas aCHECKNOT VALID. VALIDATE CONSTRAINTin a separate statement.CREATE UNIQUE INDEX CONCURRENTLY users_email_key ON users (email) WHERE email IS NOT NULL AND email <> ''.- Post-deploy: assert the index exists and is
indisvalid. - Deferred (not in this deploy): switch interim
lower()lookups to plain strict equality — only after this migration has actually executed in production (execution-run D4).
Why NOT VALID, then VALIDATE
Adding a CHECK that validates immediately takes an ACCESS EXCLUSIVE lock and full-scans under that lock. AddingNOT VALID avoids that; VALIDATE CONSTRAINT then scans under a weaker lock.
Trap (do not skip the straggler gate): a NOT VALID CHECK still validates every UPDATE of an existing row. updateUserLastSeen runs on routine cached /api/user traffic. One non-normalized straggler therefore 500s normal sessions with SQLSTATE 23514 (surfaced by Prisma as P2039 on the delegate path and P2010 on the raw path — not P2004, despite the Prisma docs; probed against @prisma/adapter-pg@7.9). Zero stragglers is a hard precondition, asserted immediately before step 2 — not a nicety.
NOT VALID also does not avoid taking the lock, only holding it. The ALTER TABLE still needs ACCESS EXCLUSIVE on users, and while it waits behind an in-flight long read it blocks every newer statement queued behind it. The .sql sets lock_timeout = '3s' around steps 1–2 for that reason and explicitly sets it to 0 before step 3 (a timeout would abort a CONCURRENTLY build partway). A 55P03 abort means nothing was applied — find the long query, retry when users is quiet.
Measured production facts
Three open questions from the cold review, all answered against production on 2026-07-26. The.sql cites them; step 0 re-checks them at run time.
What the pin is actually for
It is a forward-only tripwire, not a precondition for this run. With the table all-ASCII, existing rows are safe regardless of locale; the pin exists for the day a non-ASCII address first lands — which can only happen via a GitHub verified primary or a GoTrue JWT email, sinceupdate-email is gated by ajv’s ASCII-only format: "email".
Two mappings then diverge regardless of datctype, because PG lower() is strictly 1:1 per character while JS .toLowerCase() applies the full, partly context-sensitive Unicode mappings:
- final sigma — JS
"ΟΔΟΣ".toLowerCase()ends in U+03C2 (ς); PGlower()gives U+03C3 (σ). - U+0130
İ— JS yields two code points,i+ U+0307; PG yields one.
email = lower(btrim(email)) still holds and the CHECK never fires — no 23514, no 500. The failure is quieter: ΟΔΟΣ@x.com and οδοσ@x.com normalize to two different strings under JS’s final-sigma rule, so they occupy two rows for one real mailbox and the uniqueness design does not see them as the same address.
Step 0’s straggler count cannot find any of this — both of its sides use PG’s own lower(), so it is satisfied by any value PG considers already-folded, including one JS would fold further. The cheap non-ASCII count is the guard, in both this file and T6’s dry run, which escalates to a per-row JS↔SQL comparison only when that count comes back non-zero.
Prerequisites
There is no local Postgres / Docker / container runtime on typical agent machines. Do not claim a dry-run of this DDL without a real DB. Unit tests cover the SQLSTATE
23514 application arms only.
Ordering relative to T6
Commands
ON_ERROR_STOP is the difference between a gate and a printed warning. psql defaults it to off: without it, step 0’s RAISE EXCEPTION is printed and psql runs the next statement anyway — adding the CHECK on top of the very stragglers step 0 just refused, which is the updateUserLastSeen outage this whole file exists to prevent, reached by following the file.
The .sql now carries \set ON_ERROR_STOP on as its first executable line, so it is self-guarding. The -v ON_ERROR_STOP=1 above is belt-and-braces; keep both.
\set is a psql meta-command. Run this through anything else — a GUI client, a migration runner, psql -c per statement — and that line is ignored or rejected, so the file guarantees nothing on its own. In that case you own both halves yourself: abort on the first error, and do not open a transaction around the file (CREATE INDEX CONCURRENTLY cannot run inside one).
Step 0 — assert preconditions
The.sql runs all of these inside one enforced DO block. They are reproduced here for the case where you are inspecting production before booking the window.
Three abort, two are reported:
Do not rewrite the whitespace list usingE'...'escapes. PostgreSQL recognises only\b \f \n \r \t(plus octal/hex/unicode) in E-strings and takes any other backslashed character literally — soE'\v'is the letterv, not U+000B. That putsvin the trim set, and every address starting withvor ending in.tvthen fails the CHECK. SinceupdateUserLastSeenfires on routine/api/usertraffic, those users would 500 on every request. All 25 code points belong in the single Unicode-escape string literal — theU&form — below.
lower(). See “What the pin is actually for” above.
Steps 1–3 — DDL
As inpackages/common/prisma/ops/users-email-check-and-unique-index.sql:
SET lock_timeout = '3s'— thenALTER TABLE … ADD CONSTRAINT users_email_normalized_check … NOT VALIDALTER TABLE … VALIDATE CONSTRAINT users_email_normalized_checkSET lock_timeout = 0; SHOW lock_timeout;— thenCREATE UNIQUE INDEX CONCURRENTLY users_email_key ON users (email) WHERE email IS NOT NULL AND email <> ''
lock_timeout to 0 before step 3 is deliberate: RESET would restore
any timeout configured at startup, for the database, or for the deploy role.
The SHOW records the effective setting. A CONCURRENTLY build waits for
existing transactions by design, and a non-zero lock_timeout could abort it
partway — leaving the invalid index described below.
Build-failure loop (expected path)
Index or VALIDATE failures are normal if any row slipped past T6 or was written mid-flight. Treat the loop below as the default recovery, not an incident exception.Why the DROP comes first, always
A failedCREATE UNIQUE INDEX CONCURRENTLY leaves the index with:
indisvalid = false— the planner ignores it. It answers no queries.indisreadyis phase-dependent: it can befalsewhen a duplicate aborts the phase-2 build before the index becomes ready. If it istrue, every INSERT and UPDATE still maintains it and therefore enforces its uniqueness.
indisready = true is an enforcing write hazard; either state blocks a
replacement with the same name, and no recovery step benefits from keeping it.
Drop the index, then re-run T6.
The T6 runbook must agree with this ordering. If it says “re-run this script, then retry the concurrent unique index” without a DROP INDEX CONCURRENTLY first, that instruction is wrong and this section wins.
Post-deploy assertions
All of these must pass before calling T7 done:users_email_key exists with indisvalid = false, T7 is not done. Do
not use indisready to choose a recovery: false is possible when a duplicate
aborts phase 2 before the index is ready, and true means it is still enforced
on writes even though the planner cannot use it. In either case the object blocks
a later CREATE UNIQUE INDEX CONCURRENTLY with the same name. Drop and rebuild
before data fixes (see Why the DROP comes first).
Application belt-and-braces (SQLSTATE 23514)
After the CHECK is live, a non-normalized write fails with Postgres 23514. Detection keys on that SQLSTATE, not on a P… code: this stack emits P2039 (delegate) or P2010 (raw), never P2004.
authn-v3 handles this on user-update paths:
Stable log prefix for grepping:
EMAIL_CHECK_VIOLATION (see authn-v3 unique-constraint / reconcile modules). Expected steady state after T6+T7: 0.
Pre-deploy forced-violation test (needs live DB)
Not runnable without Postgres. Before enabling traffic confidence, on a staging clone (or a one-row throwaway):- Confirm
users_email_keyis valid. - Attempt
INSERT/UPDATEof a second row with the same non-null email → expect unique violation; record Prismameta.targetfor T1 probe (email index literal). - Attempt
UPDATEsettingemailto a non-normalized value (e.g.'Foo@Example.com') → expect CHECK / SQLSTATE23514(PrismaP2039orP2010). - Attempt a non-email
UPDATE(e.g.last_seen_atonly) on a normalized row → must succeed (CHECK not tripped).
Deferred step — switch lower() lookups to strict equality
Not part of the T7 code deploy. Execution-run D4: shipping strict equality before production data is normalized forks accounts and re-breaks the incident cohort.
Only after this runbook’s post-deploy assertions pass in production:
- Replace interim
lower(email) = $normalizedmatchers with plain equality on:- sign-in resolution
update-emailduplicate guardfindExistingUserinsensitive email fallback- dedupe tooling lookups that still use
lower()
- Leave out of scope:
team_memberships.emailjoins (un-normalized on the other side — separate follow-up). - Ship as its own PR/deploy after T7 ops, with a greppable checklist that prod
non_normalized = 0still holds.
lower() lookups remain correct and required.
Companion index — users (lower(email))
packages/common/prisma/ops/users-email-lower-index.sql. Separate file, separate step, no dependency on this runbook’s gates — run it any time from rollout step 1 onward, and earlier is better.
Every interim email lookup in authn-v3 matches lower(email) = $1. users_email_key is a plain btree on email, so it cannot serve those queries — and neither could the mode: "insensitive" form they replaced, so this is not a regression. What changed is the count: the reconcile pre-check (isEmailClaimedByAnotherUser) adds a second full scan of users to every sign-in that refreshes an email, on top of resolution’s own lookup.
Why it is not folded into the T7 .sql:
It is non-unique and non-partial on purpose (rationale in the file header). Because it is non-unique, a failed
CONCURRENTLY build leaves an invalid index that enforces nothing — unlike users_email_key, it is not a write hazard, only unusable. Drop and retry: DROP INDEX CONCURRENTLY IF EXISTS users_email_lower_idx;
Dropping it later is a deliberate separate step: verify no lower(email) matcher remains in authn-v3 first.
Prisma / schema notes
- The partial unique index is not declared as
@@uniqueonUser(inexpressible predicate — and per the measured 629email = ''rows, the predicate is load-bearing, not stylistic). prisma migrate diffmay propose droppingusers_email_key. Reject that drop. The schema comment onUser.emaildocuments this. The same applies tousers_email_lower_idx— expression indexes are equally inexpressible in the Prisma schema.- App code already treats
users_email_keyas a knownP2002target (USER_EMAIL_UNIQUE_TARGETS). - The
user_identitiesmigration is split in two —20260725190000_user_identities_and_email_source(schema,lock_timeout = '3s') then20260725190100_backfill_user_identities(backfill) — so the ACCESS EXCLUSIVE lock fromALTER TABLE "users"is not held across a full-table backfill. Both apply in oneprisma migrate deploy; if the second fails, do not start serving on the half-applied deploy (user_identitiesexists but is empty). .github/workflows/db_migrations.yamlauto-runsprisma migrate deployagainst Dev / Local / Staging on any push todevelopmenttouchingpackages/common/prisma/migrations/**. A55P03 lock_timeoutthere rolls back cleanly but records a failed migration that blocks later deploys — clear it withprisma migrate resolve --rolled-back <name>and re-deploy. Recovery details are in each migration’s header.
Failure / recovery summary
Out of scope
- Switching
lower()→ equality (deferred step above) - Monitoring dashboards
team_memberships.emailnormalization- Farm / trial clawback