# Account linking support runbook Source: https://docs.traycer.ai/account-linking-support-runbook # 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 | Table / column | Meaning | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `users.provider_id` | The account's **canonical** identity. Immutable after creation — token and owner binding depend on it. Never edit it to resolve a ticket. | | `user_identities` | The identity **registry**. One row per identity that may mint a session for an account. `is_canonical = true` mirrors `users.provider_id`; `is_canonical = false` is a *linked* identity added through the account-link flow. `provider_id` is globally unique, which is what stops one identity from spanning two accounts. | | `users.email` | The canonical account address. `email_source = USER` means the user set it in Settings and provider sign-ins must leave it alone. | | `users.former_email` | Recoverability slot for a row that lost an email-dedupe tie-break. **Not** live access — see "Un-park" below. | 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 ("`` 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 ```sql theme={null} SELECT u.id AS user_id, u.email, u.provider_type AS canonical_provider_type, u.provider_id AS canonical_provider_id, i.id AS identity_row_id, i.provider_type, i.provider_id, i.is_canonical, i.created_at FROM users u JOIN user_identities i ON i.user_id = u.id WHERE lower(u.email) = lower($1) ORDER BY i.is_canonical DESC, i.created_at; ``` 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 ```sql theme={null} DELETE FROM user_identities WHERE id = $1 AND is_canonical = false; ``` 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`): ```text theme={null} authenticatedUser: authenticatedUserV2: prismaUser: prismaUser: # users.provider_id, NOT the unlinked one userSubscription: collaboratorUserProfile: ``` **Group 2 — one `creditSummary::` 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 `` alone, so **do not** delete group 1 and then go looking — the blobs you need are gone. Match the keys first: ```bash theme={null} # 1. Discover the creditSummary keys BEFORE deleting anything. REDIS_URL='rediss://...' redis-cli -u "$REDIS_URL" --scan --pattern "creditSummary::*" ``` Then delete both groups together: ```bash theme={null} redis-cli -u "$REDIS_URL" DEL \ "authenticatedUser:" "authenticatedUserV2:" \ "prismaUser:" "prismaUser:" \ "userSubscription:" "collaboratorUserProfile:" \ "creditSummary::" # repeat per id from the scan ``` 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 ```sql theme={null} SELECT id, email, former_email, email_source, last_seen_at, created_at FROM users WHERE lower(former_email) = lower($1); ``` Then find who holds the address now: ```sql theme={null} SELECT id, email, email_source, last_seen_at, created_at FROM users WHERE lower(email) = lower($1); ``` ### 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_id`s and the subscription/team state of each. ### 2.4 Restore ```sql theme={null} UPDATE users SET email = lower(btrim(former_email, U&'\0009\000A\000B\000C\000D\0020\00A0\1680\2000\2001\2002\2003\2004\2005\2006\2007\2008\2009\200A\2028\2029\202F\205F\3000\FEFF')), former_email = NULL, email_source = 'USER', updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND email IS NULL AND former_email IS NOT NULL AND btrim(former_email, U&'\0009\000A\000B\000C\000D\0020\00A0\1680\2000\2001\2002\2003\2004\2005\2006\2007\2008\2009\200A\2028\2029\202F\205F\3000\FEFF') <> '' AND NOT EXISTS ( SELECT 1 FROM users w WHERE lower(w.email) = lower(btrim(users.former_email, U&'\0009\000A\000B\000C\000D\0020\00A0\1680\2000\2001\2002\2003\2004\2005\2006\2007\2008\2009\200A\2028\2029\202F\205F\3000\FEFF')) ); ``` **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_id`s. 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. # Organizations and Teams Source: https://docs.traycer.ai/account/organizations Provides centralized billing, seat management, privacy controls, and team collaboration. Organizations and teams are account-level features for seats, shared billing, and team access. ## Adding organizations