Skip to main content

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

  1. Assert the database locale is the one JS-parity was derived against, zero stragglers, and zero duplicate non-empty emails (hard gates).
  2. Add users_email_normalized_check as a CHECK NOT VALID.
  3. VALIDATE CONSTRAINT in a separate statement.
  4. CREATE UNIQUE INDEX CONCURRENTLY users_email_key ON users (email) WHERE email IS NOT NULL AND email <> ''.
  5. Post-deploy: assert the index exists and is indisvalid.
  6. 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. Adding NOT 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, since update-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 (ς); PG lower() gives U+03C3 (σ).
  • U+0130 İ — JS yields two code points, i + U+0307; PG yields one.
The consequence is not an outage. Both divergent forms are already lowercase as far as PG is concerned, so 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

If T6 was run days earlier, re-assert counts at T7 step 0 — do not trust an old export.

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:
Then the two counts that can 500 production, which must both be 0:
Do not rewrite the whitespace list using E'...' escapes. PostgreSQL recognises only \b \f \n \r \t (plus octal/hex/unicode) in E-strings and takes any other backslashed character literally — so E'\v' is the letter v, not U+000B. That puts v in the trim set, and every address starting with v or ending in .tv then fails the CHECK. Since updateUserLastSeen fires on routine /api/user traffic, those users would 500 on every request. All 25 code points belong in the single Unicode-escape string literal — the U& form — below.
If either is non-zero: stop. Re-run T6 (runbook), then return here. Do not add the CHECK. Note the straggler query cannot detect a JS↔SQL case-folding divergence — both of its sides use PG’s own lower(). See “What the pin is actually for” above.

Steps 1–3 — DDL

As in packages/common/prisma/ops/users-email-check-and-unique-index.sql:
  1. SET lock_timeout = '3s' — then ALTER TABLE … ADD CONSTRAINT users_email_normalized_check … NOT VALID
  2. ALTER TABLE … VALIDATE CONSTRAINT users_email_normalized_check
  3. SET lock_timeout = 0; SHOW lock_timeout; — then CREATE UNIQUE INDEX CONCURRENTLY users_email_key ON users (email) WHERE email IS NOT NULL AND email <> ''
Setting 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 failed CREATE UNIQUE INDEX CONCURRENTLY leaves the index with:
  • indisvalid = false — the planner ignores it. It answers no queries.
  • indisready is phase-dependent: it can be false when a duplicate aborts the phase-2 build before the index becomes ready. If it is true, every INSERT and UPDATE still maintains it and therefore enforces its uniqueness.
Either reading has the same recovery: drop the incomplete index before fixing data. 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.
Confirm the drop landed before touching data:
Then:
Repeat until VALIDATE and the concurrent index both succeed.

Post-deploy assertions

All of these must pass before calling T7 done:
If 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):
  1. Confirm users_email_key is valid.
  2. Attempt INSERT/UPDATE of a second row with the same non-null email → expect unique violation; record Prisma meta.target for T1 probe (email index literal).
  3. Attempt UPDATE setting email to a non-normalized value (e.g. 'Foo@Example.com') → expect CHECK / SQLSTATE 23514 (Prisma P2039 or P2010).
  4. Attempt a non-email UPDATE (e.g. last_seen_at only) 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:
  1. Replace interim lower(email) = $normalized matchers with plain equality on:
    • sign-in resolution
    • update-email duplicate guard
    • findExistingUser insensitive email fallback
    • dedupe tooling lookups that still use lower()
  2. Leave out of scope: team_memberships.email joins (un-normalized on the other side — separate follow-up).
  3. Ship as its own PR/deploy after T7 ops, with a greppable checklist that prod non_normalized = 0 still holds.
Until that follow-up lands, T3’s interim 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 @@unique on User (inexpressible predicate — and per the measured 629 email = '' rows, the predicate is load-bearing, not stylistic).
  • prisma migrate diff may propose dropping users_email_key. Reject that drop. The schema comment on User.email documents this. The same applies to users_email_lower_idx — expression indexes are equally inexpressible in the Prisma schema.
  • App code already treats users_email_key as a known P2002 target (USER_EMAIL_UNIQUE_TARGETS).
  • The user_identities migration is split in two — 20260725190000_user_identities_and_email_source (schema, lock_timeout = '3s') then 20260725190100_backfill_user_identities (backfill) — so the ACCESS EXCLUSIVE lock from ALTER TABLE "users" is not held across a full-table backfill. Both apply in one prisma migrate deploy; if the second fails, do not start serving on the half-applied deploy (user_identities exists but is empty).
  • .github/workflows/db_migrations.yaml auto-runs prisma migrate deploy against Dev / Local / Staging on any push to development touching packages/common/prisma/migrations/**. A 55P03 lock_timeout there rolls back cleanly but records a failed migration that blocks later deploys — clear it with prisma 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.email normalization
  • Farm / trial clawback