Support runbook — account linking & email identity
Operational procedures for the cross-provider sign-in work: unlinking an identity, un-parking aformer_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.
1. Unlink an 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
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
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):
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:
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
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_keyprotects 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
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 theThen purge the user cache for thatU&'…'form. PostgreSQL’sE'…'escapes recognise only\b \f \n \r \t(plus octal/hex/unicode) and take any other backslashed character literally, soE'\v'is the letterv— which would stripvfrom both ends of the address.
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”
- 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 capin the authn-v3 logs. - Check for a bounce in customer.io for the address in
users.email. - 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 anotheruser_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, captureusers.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.