Skip to main content

Support runbook — account linking & email identity

Operational procedures for the cross-provider sign-in work: unlinking an identity, un-parking a former_email, and the diagnostics that decide which of the two a ticket needs. This runbook is a prerequisite for enabling Tier 1 — a linked identity has no self-service removal, so support has to be able to remove one before the platform starts creating them silently. Related: docs/email-normalize-dedupe-migration-runbook.md (the dedupe pass that produces parked former_email values).

Background: the two tables support touches

A linked identity can sign in to the account. It can never rewrite the account’s handle, name, avatar or email — those stay owned by the canonical identity.
Use when: a user reports a sign-in method on their account that they did not add, or asks to detach one (e.g. a work GitHub account they no longer use).

1.1 Confirm the account holder

Do not proceed on the strength of the inbound email address alone. The notification mail (“<identity> can now sign in to your Traycer account”) is sent to users.email, so a requester who can quote it demonstrates control of the canonical mailbox. Otherwise use the normal account-ownership check.

1.2 Find the rows

Expect exactly one is_canonical = true row (it mirrors users.provider_id) plus one row per linked identity.

1.3 Delete the linked row — never the canonical one

The AND is_canonical = false is not decoration. Deleting the canonical row leaves users.provider_id pointing at an identity the registry no longer fences, which re-opens the “one identity, two accounts” hole for that id. Always delete by id, verified against the query above. Verify one row was removed. If the delete affected zero rows, the identity was canonical — see 1.5.

1.4 Purge the user cache

Sign-in resolution reads a 24-hour Redis cache before Postgres, so the row you just changed can keep being served stale. removeUserFromCache (packages/common/src/cache/user-cache.ts) evicts two groups, and doing it by hand means doing both. Keep this list in sync with that function. Group 1 — the six fixed keys (getUserCacheKeys):
Group 2 — one creditSummary:<user_id>:<customer_id> per customer id, which removeUserFromCache discovers by reading the cached blobs first and collecting every customerId it finds in them: the user’s own subscription, the v2 blob’s teamSubscriptions, the v1 blob’s organizationSubscriptions, and the userSubscription blob. That set is not derivable from <user_id> alone, so do not delete group 1 and then go looking — the blobs you need are gone. Match the keys first:
Then delete both groups together:
If the scan returns nothing, there is nothing to evict in group 2 — that is a normal outcome for a user who has never had a credit summary computed. A leftover creditSummary key is not a sign-in problem; it shows the user stale credit figures until it expires. The registry table itself is not cached, so the unlink takes effect on the next sign-in either way; the purge is for the account row the user then sees. Anything already issued stays valid: a Traycer token minted before the unlink is bound to user_id, not to the identity. If this is a suspected-compromise ticket, escalate for token revocation as well — unlinking alone only stops future sign-ins.

1.5 If the identity to remove is canonical

Stop and escalate. Detaching a canonical identity is an account migration, not an unlink: users.provider_id is immutable by design, and the correct resolution is usually to link the user’s preferred identity (they can do it themselves by signing in with it and entering the emailed code) and leave the old one in place.

2. Un-park a former_email

Use when: a user reports that their email address vanished from their account, or that they have lost team access after the dedupe pass. Gate: run this procedure only after T7’s users_email_key has been created and verified valid. Between T6 parking and T7, there is no unique constraint on users.email; escalate the ticket rather than mechanically restoring an address.

2.1 Understand what parking did and did not do

former_email preserves the value so it can be restored. It does not preserve access: every team-membership join reads users.email and degrades to no-access when it is NULL, regardless of what sits in former_email. A parked user with an accepted team membership is therefore a live incident, not a cosmetic one.

2.2 Find the parked row and its collision

Then find who holds the address now:

2.3 Decide

  • Nobody holds it (second query returns no rows) — the winner was deleted or changed address. T7 is still required: the statement in 2.4 repeats this holder check atomically, and users_email_key protects against a concurrent writer after that check. Go to 2.4.
  • Someone holds it — the address is one-owner. You cannot restore it to a second row. Either the current holder releases it (via Settings → change email), or the two accounts are merged. Both are decisions, not mechanical steps: escalate with both user_ids and the subscription/team state of each.

2.4 Restore

Normalize on the way back in — do not copy former_email verbatim. After T7 the column carries users_email_normalized_check, and that CHECK validates the whole new tuple, so restoring an address that is not already lower + JS-whitespace-trimmed fails with SQLSTATE 23514 on a statement this runbook told you to run. The dedupe pass normalizes before it parks, so in the common case this is a no-op — but former_email carries no constraint of its own and nothing guarantees it for a row parked by hand or by an older tool. The <> '' guard is the matching case: a value that trims to empty would otherwise be restored as '', which is outside the unique index entirely and silently un-owns the address. The holder check is lower() on both sides for the same reason — comparing a normalized column against a possibly-unnormalized former_email is exactly how a live holder goes unseen, and that is the case where the guard is load-bearing rather than decorative. email_source = 'USER' is deliberate: a restored address must not be overwritten by the next provider sign-in, which is the defect that made the address unstable in the first place. The NOT EXISTS predicate closes the gap between the advisory lookup in 2.2 and the write: a holder visible to the restore statement makes it affect zero rows. users_email_key protects the remaining concurrent-write window. If the update affects zero rows or raises a unique violation, re-run 2.2 and escalate rather than retrying it blindly.
The trim set must be the U&'…' form. PostgreSQL’s E'…' escapes recognise only \b \f \n \r \t (plus octal/hex/unicode) and take any other backslashed character literally, so E'\v' is the letter v — which would strip v from both ends of the address.
Then purge the user cache for that user_id (see 1.4) — GET /user serves an authenticatedUser blob with the old value for 24h otherwise, and the pending-invite auto-join keys off the email inside it.

3. Common tickets

”I get a code entry box but no email arrives”

  1. Check the send caps — the address is capped at 5 codes/hour and 10/day, and the cap fails closed, so a Redis outage also produces this. Look for Link code send refused by cap in the authn-v3 logs.
  2. Check for a bounce in customer.io for the address in users.email.
  3. Note that a reload does not re-send: the ticket mint is idempotent within its 15-minute window, so the original code stays valid and no second email is sent. Tell the user to look for the first one.

”The code says it’s wrong but I’m typing it correctly”

The ticket allows 5 attempts and is then destroyed. Ask the user to sign in again to get a fresh code. Codes are single-use and ticket-bound — a code from an earlier attempt, or from the Settings email-change flow, will not work here.

”It says my GitHub account is already linked to a different account”

The presenting identity is registered to another user_id. This is a merge, not an unlink: run the 1.2 query against both addresses and escalate with both user_ids. Do not delete the other account’s registry row to “free up” the identity — that silently removes the other user’s ability to sign in.

”I signed in and my email reverted to an old address”

Should no longer happen. If it does, capture users.email_source for the row: a PROVIDER value on an address the user set in Settings means the Settings write did not land, which is an engineering escalation, not a support fix.

4. What support cannot do (yet)

  • Self-service unlink — deferred; this runbook is the interim.
  • Listing linked identities in Settings — deferred; use the 1.2 query.
  • Merging two accounts — no mechanism exists. Escalate.