Runbook: Email normalize + dedupe migration (T6)
Production data migration for the account-linking / email-identity plan (Rev 4, rollout step 3).What this does
- Pre-flight audit — duplicate groups (normalized email), non-normalized row count,
email = ''population, and thelower()parity precondition. - Full-table normalize —
lower()+ trim of the enumerated JS-whitespace class used bynormalizeEmail(not Postgresbtrim/[[:space:]]alone — those miss NBSP/BOM). - Purge the normalize-touched users’ caches immediately — before the post-normalize gate can halt the run.
- Collision re-detect after normalize (case-variant twins).
- Ranked dedupe — keep one winner per address; park losers.
- Hard manual gate — refuse mechanical park while any loser holds an ACCEPTED team membership or a non-PENDING personal subscription.
- Redis purge — full user-cache key set mirroring
removeUserFromCache/getUserCacheKeysfor winners, losers, and normalized-only rows:authenticatedUser:<id>,prismaUser:<id>,prismaUser:<providerId>,userSubscription:<id>,collaboratorUserProfile:<id>,authenticatedUserV2:<id>, pluscreditSummary:<id>:<customerId>for every customerId recovered from DB and/or cached blobs. (Partial prismaUser-only purge is wrong: auth caches embed email and are 24h read-through.) - Farm-group export — sizes
{18,24,43,48,49}for trust/safety only (no enforcement).
Rows this migration deliberately does not touch
users.email = '' is outside T7’s partial unique index
(WHERE email IS NOT NULL AND email <> '') — the product allows those rows, and
production holds 629 of them (2026-07-26).
They are therefore never grouped as a collision, never ranked, and never parked.
The predicate is evaluated on the normalized value, because the normalize
pass folds whitespace-only values (" ", NBSP, BOM…) to '' and so creates
more of them. Normalization still applies to those rows: lower(btrim(' ')) is
'', so a whitespace-only value would fail T7’s CHECK if it were left unfolded.
The dry run prints the population and exports it to
email-dedupe-empty-email-*.json. If a park plan ever names one, the executor
refuses before writing anything (see Pre-write preconditions).
Parking semantics (do not overstate)
Moving a loser’semail → former_email preserves recoverability of the address value only.
It does not preserve live team access. Team-join sites read users.email and degrade to no-access when it is NULL, regardless of former_email.
Prerequisites
2026-07-25 baseline: 66 duplicated addresses across 392 rows (all GitHub-keyed).
One known dual live-row address is a hard gate requiring an explicit ack.
It is deliberately not written down here or in the source: the script pins
it by SHA-256 and the dry run names the actual address in
email-dedupe-manual-gate-*.json, alongside the exact command to copy. Below,
<gated-address> stands for that value.
Commands
Dry-run is default. Nothing writes without--execute.
--ack-manual-decision= is mandatory on every invocation that would park the
known dual-row address, including re-runs and T7’s index-failure recovery loop
— it is not sticky. Every refusal prints the exact command that satisfies the
gates; copy that line rather than reconstructing one. An ack naming any other
address is rejected at parse time (a typo used to be accepted silently and then
hit the same exit 2 with no explanation).
--skip-cache-purge is debug-only. Combined with --execute it prints a loud warning and writes email-dedupe-skipped-cache-purge-*.json with user ids and keys that still need purging — do not use in production. Sweep the fallout with --purge-user-ids-from=<that file> --execute.
Output directory
Exports contain full lists of production email addresses.--output-dir
defaults to ./.email-dedupe-out, which is git-ignored, and
email-dedupe-*.json is ignored repo-wide as a second line of defence. Do not
redirect them somewhere tracked.
Timeouts
getPrismaPgConfig defaults query_timeout to 30s — a web-request budget. This
script issues an unbounded SELECT over every in-scope user joined to
subscriptions, plus one unindexed full-table UPDATE, so it raises both
query_timeout and statement_timeout to 30 minutes for its own connection
and prints the effective value on startup. An explicit ?query_timeout=<ms> in
DATABASE_URL still wins if you need something else.
JSON exports (timestamped)
Ranking
For each collision group (same normalized email, ≥2 rows):last_seen_at DESC NULLS LAST- personal subscription
customer_idpresent beats absent activated_at DESC NULLS LASTcreated_at DESCid ASC(stable tie-break for idempotent re-runs)
email. Each loser: former_email = email, email = NULL.
Manual gate
A loser is gated (and--execute refuses the whole park pass, exit 2) when:
- there is an ACCEPTED
team_membershipsrow whose email normalizes to the same address, or - the user has a personal subscription with
subscription_status <> PENDING, or - the group is the known dual-live-row address and the operator did not pass
--ack-manual-decision="$GATED_ADDRESS"(after support decides which row wins). The dry run names that address; the script never stores it.
--execute. For the known dual-row address, decide the winner offline, then re-run with the ack flag. Do not bypass the gate by editing the script.
Normalize may still run; if the gate is non-empty after normalize, the script halts before parking. That halt is safe to re-run because step 3 already purged the normalize-touched users’ caches — see Cache purge ordering.
Pre-window feasibility query (run this before booking the window)
The gate treats any non-PENDING subscription status as a block, and FREE
is a SubscriptionStatus. If production eagerly creates FREE rows, essentially
every loser is gated and --execute can never proceed. That is a question about
the real distribution, not a bug in the gate — so check it first:
FREE, decide
with support before the window whether FREE is one of the statuses the plan
meant to gate. Either narrow the gate deliberately (a plan change, not a script
edit) or accept that the queue must be cleared by hand. Discovering this during
the window means the window is wasted.
The dry run reports the same thing from the plan side — MANUAL-GATE BREAKDOWN
counts gated losers by reason and by subscription_status, and warns explicitly
when every loser is gated.
Pre-write preconditions
Anything knowable from the plan alone is checked before the first write, and the executor refuses (exit 2) rather than discovering it afterwards:- no park plan may carry an empty
normalizedEmail/emailBefore(anemail = ''row must never be parked) - every park plan’s stored email must normalize to its own group key
- no plan may park the winner of its own group, and no loser may appear twice
- JS↔Postgres
lower()parity must hold (see below)
Idempotency
- Normalize only updates rows where
email <> lower(btrim(email, <js-ws>)). - Park only updates rows that still hold a matching non-null email.
- Re-run after success: zero normalize updates, zero parks, assertions still pass.
Cache purge ordering
The purge runs twice, and the first one is load-bearing:- Right after the normalize
UPDATE— for every normalize-touched user, before the post-normalize manual gate can halt the run. - After parking — for winners, parked losers and normalize-touched users.
normalizeDiff, which is
computed on the pre-normalize snapshot, so run 2 recomputes it against
already-normalized rows and gets [].
If a run dies harder than that (crash, killed session) between the normalize
UPDATE and its purge, the ids are still on disk —
email-dedupe-normalize-diff-*.json is written before normalization starts:
--execute that mode prints the plan and deletes nothing. It also
accepts email-dedupe-skipped-cache-purge-*.json and
email-dedupe-cache-purge-*.json.
Parking has a separate recovery source because normalization can create a new
collision that did not exist in the preflight park plan. Immediately before the
first loser UPDATE, the executor writes
email-dedupe-park-recovery-*.json, including the final post-normalize park
set and every customer id it can recover for the credit-summary sweep. If the
process dies during parking or before its Step 5 purge finishes, run:
lower() parity (forward-only concern — not a live defect)
normalizeEmail is JS .trim().toLowerCase(); the SQL is
lower(btrim(email, <js-ws>)). The whitespace class is pinned and tested both
directions. The case-folding half is not pinned: PG lower() is
lc_ctype-dependent and strictly 1:1 per character, while JS .toLowerCase()
is locale-invariant with full Unicode mappings. The straggler count cannot see a
divergence — both of its sides use PG’s own lower().
For printable ASCII the two are identical under every locale including C,
so the dry run checks that precondition rather than doing per-row work:
- 0 (production, 2026-07-26) → parity holds by construction; logged, no row-level scan.
- > 0 → the script loads exactly those rows with Postgres’ own
lower(btrim(…))and compares row-by-row againstnormalizeEmail. Any mismatch is printed and blocks--executevia the pre-write preconditions.
Σ to final sigma (ΟΔΟΣ@x.com → οδοσ@x.com) where a 1:1 PG
lower() does not. That is a uniqueness leak, not a 500. T7 step 0 records
datcollate/datctype as the backstop.
Post-run assertions (automatic on --execute)
- Zero duplicate groups among rows inside T7’s index predicate
(
email IS NOT NULL AND email <> ''—''rows are exempt and are not a duplicate group) - Zero non-normalized rows
- Every planned parked loser has
email IS NULLandformer_emailpopulated (a legitimately-empty planned value is not a complaint — and can no longer be planned) - No cached blob still serves a pre-migration address
/api/user hit repopulates authenticatedUser:<id>
with correct post-migration data while the sweep is still running, so an
existence check fails a successful migration and gives the operator no way to
tell that from a real one. Reappeared keys are reported as an expected note; the
run only fails if a blob still contains a parked address or a pre-normalize
value.
Failure / recovery
Why the drop must come first
After a failedCREATE UNIQUE INDEX CONCURRENTLY, users_email_key remains an
invalid index and reserves its name. indisready is phase-dependent: a
duplicate encountered during the phase-2 build can leave it false, while a
concurrent duplicate caught during phase-3 validation can leave it true.
PostgreSQL documents update overhead for failed concurrent builds, so do not use
either value to infer whether the leftover participates in writes or whether it
is safe to keep. The recovery action is the same in both cases: drop the failed
index before retrying so the name is available and no leftover state affects the
retry.
Order is unconditionally: DROP INDEX CONCURRENTLY IF EXISTS users_email_key
→ re-run T6 (--execute --ack-manual-decision=…) → re-assert T7 step 0 →
recreate the index. The T7 runbook
(docs/email-uniqueness-check-and-index-runbook.md)
is authoritative for the DDL half.
JS whitespace class
Must matchString.prototype.trim() (see scripts/email-normalize-dedupe-lib.ts):
U+0009..U+000D, U+0020, U+00A0, U+1680, U+2000..U+200A, U+2028, U+2029, U+202F, U+205F, U+3000, U+FEFF.
Postgres [[:space:]] / default btrim alone are not sufficient.
Out of scope
- CHECK + unique index DDL (T7) — see
docs/email-uniqueness-check-and-index-runbook.md. Run T7 only after this script’s post-run assertions pass (zero stragglers, zero duplicates). - authn-v3 write-path changes (T2/T3)
- Farm enforcement / trial clawback (separate T&S work)
team_memberships.emailnormalization (noted follow-up)