> ## Documentation Index
> Fetch the complete documentation index at: https://docs.traycer.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 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 ("`<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

```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:<user_id>
authenticatedUserV2:<user_id>
prismaUser:<user_id>
prismaUser:<canonical_provider_id>      # users.provider_id, NOT the unlinked one
userSubscription:<user_id>
collaboratorUserProfile:<user_id>
```

**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:

```bash theme={null}
# 1. Discover the creditSummary keys BEFORE deleting anything.
REDIS_URL='rediss://...'
redis-cli -u "$REDIS_URL" --scan --pattern "creditSummary:<user_id>:*"
```

Then delete both groups together:

```bash theme={null}
redis-cli -u "$REDIS_URL" DEL \
  "authenticatedUser:<user_id>" "authenticatedUserV2:<user_id>" \
  "prismaUser:<user_id>" "prismaUser:<canonical_provider_id>" \
  "userSubscription:<user_id>" "collaboratorUserProfile:<user_id>" \
  "creditSummary:<user_id>:<customer_id>"    # 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.
