DESIGN-userEdited-drafts.md30.4 KBView on GitHub
# Simplified Draft Ownership with `userEdited`

## 1) Introduction — goal, present state, future state

We want a small, predictable contract for client-owned drafts so refresh loops and prefetch races stop, and we want to delete the generation/identity machinery layered on top in this branch. Today, `ParsedMessage` carries both `draftSessionId` and `localSessionId`, the thread slice runs a multi-step identity merge (`getDraftIdentity`, `mergeThreadMessagesWithDraftIdentity`, `didDraftSetChange`), and `email-composer.tsx` adds save/send generation guards plus a "fallback newest draft" rebind path — and the refresh-creates-new-draft bug is still present. The future state collapses ownership to three fields on a draft message — `draftSessionId` (stable frontend UUID), `draftId` (Gmail canonical id, kept in lockstep with the server response), and `userEdited` (boolean set true on composer mount for a new draft and on first editor change for an existing one) — and makes the `mail.get` merge a single rule: any local draft with `userEdited === true` is protected, period.

## 2) Present state

### 2.1 Architecture diagram

```text
                       [mail.get foreground]      [mail.get prefetch x20]
                                 |                          |
                                 v                          v
                          [setThreadData]            [batchSetThreadData]
                                 |                          |
                                 +-------------+------------+
                                               v
                              [mergeThreadMessagesWithDraftIdentity]
                              [getDraftIdentity, didDraftSetChange]
                              [hasLocalDraftOwnership via localSessionId]
                                               |
                                               v
                                  [threadData.messages]
                                               ^
                                               |
[Composer mount] ----- localDraftSessionIdRef -+
       |                                       |
       v                                       |
[editor onUpdate -> setHasUnsavedChanges(true)]+
       |                                       |
       v                                       |
[markDraftAsLocallyOwned(threadId, identity)] -+
       |                                       |
       v                                       |
[saveDraft]                                    |
  - generationTrackerRef.begin('save')         |
  - getCurrentDraftInfo                        |
      - identity lookup                        |
      - selectFallbackExistingDraft (newest)   |
  - optimistic setThreadData/setQueryData      |
  - drafts.create                              |
  - on success: rewrite by draftSessionId      |
  - drop if generation stale                   |
       |
       v
[proceedWithSend]
  - generationTrackerRef.begin('send')
  - optimisticSendDraft
  - await pendingSavePromise
  - mail.send
  - invalidate mail.get
  - drop completion/error/undo if stale
```

### 2.2 Step-by-step walkthrough

1. **Composer mounts and seeds a local session id** — `EmailComposer` at [email-composer.tsx:734](apps/mail/modules/drafting/components/email-composer.tsx) captures `draftSessionId` from props and mirrors it into `localDraftSessionIdRef`.
   - Receives: `draftSessionId` (UUID passed by the parent, or `temp-draft-${Date.now()}` from `injectDraftIntoThread` at [draftSlice.ts:391](apps/mail/modules/drafting/store/draftSlice.ts))
   - Calls: `useRef(createComposerGenerationTracker())` at [email-composer.tsx:734](apps/mail/modules/drafting/components/email-composer.tsx), `useRef(draftSessionId)` at [email-composer.tsx:735](apps/mail/modules/drafting/components/email-composer.tsx)
   - Data after this step:
     ```json
     {
       "localDraftSessionIdRef.current": "0c5946c6-be52-4909-b8b4-f845d6421900",
       "generationTrackerRef.current": { "save": 0, "send": 0, "delete": 0 }
     }
     ```

2. **`getCurrentDraftInfo` searches the store with a four-key identity** — at [email-composer.tsx:789](apps/mail/modules/drafting/components/email-composer.tsx), `resolveDraftIdentity` collapses `localSessionId ?? draftSessionId ?? draftId ?? id` and matches drafts by equality with `draftSessionId` prop.
   - Receives: `draftSessionId`, `threadId`, `emailHeaderMessageIdProp`
   - Calls: `getThreadData(threadId)`, `resolveDraftIdentity` at [email-composer.tsx:740](apps/mail/modules/drafting/components/email-composer.tsx), `selectFallbackExistingDraft` at [email-composer.tsx:746](apps/mail/modules/drafting/components/email-composer.tsx)
   - Data after this step:
     ```json
     {
       "draftId": null,
       "messageId": "gm_msg_1",
       "currentThreadId": "thread_123",
       "fallbackUsed": "newestProviderDraft"
     }
     ```

3. **Editor change marks the draft locally owned** — at [email-composer.tsx:884](apps/mail/modules/drafting/components/email-composer.tsx), an effect on `hasUnsavedChanges` calls `markDraftAsLocallyOwned`.
   - Receives: `threadId`, `draftIdentity = localDraftSessionIdRef.current`
   - Calls: `markDraftAsLocallyOwned` at [threadSlice.ts:1941](apps/mail/modules/threads/threadList/store/threadSlice.ts), which writes `localSessionId` onto the matching draft row.
   - Data after this step:
     ```json
     {
       "draftRow": {
         "id": "temp-draft-1700000000000",
         "draftId": null,
         "localSessionId": "0c5946c6-...",
         "draftSessionId": "0c5946c6-..."
       }
     }
     ```

4. **`setThreadData` runs the identity merge on `mail.get`** — at [threadSlice.ts:814](apps/mail/modules/threads/threadList/store/threadSlice.ts), `mergeThreadMessagesWithDraftIdentity` keeps locally-owned drafts (those with `localSessionId`) and dedupes by canonical identity.
   - Receives: `existing.messages`, `data.messages`
   - Calls: `getDraftIdentity` at [threadSlice.ts:640](apps/mail/modules/threads/threadList/store/threadSlice.ts), `doDraftsReferToSameEntity` at [threadSlice.ts:644](apps/mail/modules/threads/threadList/store/threadSlice.ts), `hasLocalDraftOwnership` at [threadSlice.ts:661](apps/mail/modules/threads/threadList/store/threadSlice.ts), `didDraftSetChange` at [threadSlice.ts:681](apps/mail/modules/threads/threadList/store/threadSlice.ts)
   - Data after this step:
     ```json
     {
       "messages": [
         "...server non-drafts...",
         "...server drafts (possibly carrying local draftSessionId/localSessionId)...",
         "...unmatched local drafts kept by localSessionId..."
       ]
     }
     ```

5. **`batchSetThreadData` runs the same merge for prefetch** — at [threadSlice.ts:930](apps/mail/modules/threads/threadList/store/threadSlice.ts), prefetch payloads go through the identical helper.
   - Receives: `Record<threadId, ThreadDataLike>`
   - Calls: `mergeThreadMessagesWithDraftIdentity` per thread.
   - Data after this step:
     ```json
     {
       "batchSemantics": "matches foreground setThreadData semantics"
     }
     ```

6. **`saveDraft` opens a save generation, then optimistically writes** — at [email-composer.tsx:988](apps/mail/modules/drafting/components/email-composer.tsx), `generationTrackerRef.current.begin('save')` increments before `setThreadData`/`setQueryData`.
   - Receives: form/editor state
   - Calls: `getCurrentDraftInfo`, `setThreadData` (optimistic), `setQueryData` (optimistic), `createDraft` (`trpc.drafts.create` at [drafts.ts:32](apps/server/src/trpc/routes/drafts.ts))
   - Data after this step:
     ```json
     {
       "operation": "saving",
       "saveGeneration": 3,
       "optimisticDraft": { "draftSessionId": "0c5946c6-...", "draftId": null }
     }
     ```

7. **Save success rewrites the row and checks generation** — at [email-composer.tsx:1082](apps/mail/modules/drafting/components/email-composer.tsx) the callback short-circuits if `!isCurrentGeneration(saveGeneration)`; otherwise it rewrites the draft row's `id`/`draftId`/`threadId`.
   - Receives: `{ responseDraftId, responseMessageId, responseThreadId }`
   - Calls: `setThreadData`, `setQueryData`
   - Data after this step:
     ```json
     {
       "draftRow": { "id": "gm_msg_1", "draftId": "gd_1", "draftSessionId": "0c5946c6-..." }
     }
     ```

8. **`proceedWithSend` opens a send generation and may await pending save** — at [email-composer.tsx:958](apps/mail/modules/drafting/components/email-composer.tsx), the send path uses `optimisticSendDraft`, then awaits `savePromiseRef.current`, then calls `mail.send`. Stale completion/error/undo callbacks are dropped via `isCurrentGeneration(sendGeneration)` at [email-composer.tsx:1481](apps/mail/modules/drafting/components/email-composer.tsx).
   - Receives: `shouldArchive`, current operation state
   - Calls: `optimisticSendDraft` at [threadSlice.ts:2147](apps/mail/modules/threads/threadList/store/threadSlice.ts), `sendEmail`, `invalidateQueries`
   - Data after this step:
     ```json
     {
       "store": { "draftOptimisticallyRemoved": true },
       "sendGeneration": 4,
       "pendingSavePromise": "may resolve after send begins"
     }
     ```

9. **`handleDeleteDraft` resolves identity then deletes** — at [email-composer.tsx:1633](apps/mail/modules/drafting/components/email-composer.tsx), it begins a `delete` generation, removes the message from the thread, updates the query cache, and calls `drafts.delete`.
   - Receives: `getCurrentDraftInfo()` result
   - Calls: `removeMessageFromThread`, `setQueryData`, `deleteDraftMutation` (`trpc.drafts.delete`)
   - Data after this step:
     ```json
     {
       "store": { "draftRemoved": true },
       "operation": "deleting"
     }
     ```

10. **Failure mode that keeps happening** — refresh hits a provider draft whose row no longer matches `draftSessionId` (server doesn't echo it), `getCurrentDraftInfo` may return `currentDraftId: null`, the next autosave issues `drafts.create` without `draftId`, and Gmail creates a duplicate. The fallback "newest provider draft" path at [email-composer.tsx:746](apps/mail/modules/drafting/components/email-composer.tsx) papers over symptoms but can rebind to the wrong row when multiple drafts coexist.

## 3) Designed state

### 3.1 Architecture diagram

```text
[mail.get foreground]                  [mail.get prefetch]
        |                                       |
        v                                       v
 [setThreadData]                         [batchSetThreadData]
        |                                       |
        +-----------------+---------------------+
                          v
            [mergeDraftsByUserEdited(existing, incoming)]
                          |
                          v
                [threadData.messages]
                          ^
                          |
[Composer mount: new draft -> userEdited = true]
[Composer editor onUpdate (existing draft, first edit) -> userEdited = true]
                          |
                          v
[saveDraft]
  - reads { draftSessionId, draftId, userEdited } from store
  - optimistic write
  - drafts.create with { draftId, draftSessionId }
  - on success: write back response.draftId onto the row keyed by draftSessionId
                          |
                          v
[proceedWithSend]
  - optimisticSendDraft
  - await pendingSavePromise (so we use latest draftId)
  - mail.send
  - on success: invalidate mail.get(effectiveThreadId)
                          |
                          v
[handleDeleteDraft]
  - read draftId from store row matched by draftSessionId
  - optimistic remove
  - drafts.delete
```

### 3.2 Step-by-step walkthrough

1. **Trim `ParsedMessage` to three draft fields** — in [threadSlice.ts:51](apps/mail/modules/threads/threadList/store/threadSlice.ts), keep `draftSessionId` (stable frontend UUID), `draftId` (Gmail canonical id), and add `userEdited?: boolean`. Remove `localSessionId`.
   - Receives: existing `ParsedMessage` type usages.
   - Calls: nothing (type change).
   - Data after this step:
     ```ts
     interface ParsedMessage {
       id: string;
       draftId?: string;          // Gmail canonical id, kept in lockstep with server
       draftSessionId?: string;   // stable frontend UUID for the editing session
       userEdited?: boolean;      // true once the user has touched this draft locally
       isDraft?: boolean;
       // ...rest unchanged
     }
     ```

2. **Stamp `userEdited = true` when opening a new composer** — in `draftSlice.openDraftWithBody` at [draftSlice.ts:340](apps/mail/modules/drafting/store/draftSlice.ts) and `draftSlice.injectDraftIntoThread` at [draftSlice.ts:389](apps/mail/modules/drafting/store/draftSlice.ts), set `userEdited: true` and drop `localSessionId`. The temp `id` keeps the `temp-draft-${Date.now()}` form because send/delete still match by `id` for unsaved drafts.
   - Receives: caller-provided body/recipients.
   - Calls: `set(...)` to push the new draft row.
   - Data after this step:
     ```json
     {
       "newDraftRow": {
         "id": "temp-draft-1700000000000",
         "draftId": null,
         "draftSessionId": "0c5946c6-be52-4909-b8b4-f845d6421900",
         "userEdited": true,
         "isDraft": true
       }
     }
     ```

3. **Stamp `userEdited = true` on the first editor change for an existing draft** — in `EmailComposer` at [email-composer.tsx:884](apps/mail/modules/drafting/components/email-composer.tsx), replace the `markDraftAsLocallyOwned` effect with a new slice action `markDraftAsUserEdited(threadId, draftSessionId)`. Call it from the same `hasUnsavedChanges` effect; it must be idempotent (no-op when `userEdited` is already true).
   - Receives: `threadId`, `draftSessionId` (from `localDraftSessionIdRef.current`, which we keep as the stable key, but it now just mirrors `draftSessionId` prop with no fallback rebinding).
   - Calls: `markDraftAsUserEdited` (new in `threadSlice.ts`).
   - Data after this step:
     ```json
     {
       "draftRow": {
         "id": "gm_msg_1",
         "draftId": "gd_1",
         "draftSessionId": "0c5946c6-...",
         "userEdited": true
       }
     }
     ```

4. **Replace the identity merge with a `userEdited` merge** — in `threadSlice.ts`, delete `getDraftIdentity`, `doDraftsReferToSameEntity`, `hasLocalDraftOwnership`, `didDraftSetChange`, and `mergeThreadMessagesWithDraftIdentity`. Add a single helper `mergeDraftsByUserEdited(existingMessages, incomingMessages)` that:
   - keeps every incoming non-draft as-is,
   - for each incoming draft, if `existingMessages` has a draft with the same `draftSessionId` (or same `draftId` if `draftSessionId` is missing on the existing row) and that existing row has `userEdited === true`, **drop the incoming draft and keep the local row**,
   - otherwise prefer the incoming draft (server is truth), carrying the local `draftSessionId` onto the incoming row if matched only by `draftId`,
   - appends any `existingMessages` drafts with `userEdited === true` that didn't match anything incoming (unsaved local drafts survive `mail.get`),
   - dedupes the final draft list by `draftSessionId ?? draftId ?? id`.
   - Receives: `{ existingMessages, incomingMessages }`
   - Calls: nothing — pure function.
   - Data after this step (Case A, server draft with newer body but user has edits locally):
     ```json
     {
       "existing": [
         { "id": "gm_1", "isDraft": true, "draftId": "gd_1",
           "draftSessionId": "0c5946...", "userEdited": true,
           "processedHtml": "<p>local edits</p>" }
       ],
       "incoming": [
         { "id": "gm_1", "isDraft": true, "draftId": "gd_1",
           "processedHtml": "<p>older server body</p>" }
       ],
       "merged": [
         { "id": "gm_1", "isDraft": true, "draftId": "gd_1",
           "draftSessionId": "0c5946...", "userEdited": true,
           "processedHtml": "<p>local edits</p>" }
       ]
     }
     ```
   - Data after this step (Case B, server draft for a draft the user has NOT touched locally):
     ```json
     {
       "existing": [
         { "id": "gm_1", "isDraft": true, "draftId": "gd_1",
           "draftSessionId": "0c5946...", "userEdited": false,
           "processedHtml": "<p>old client body</p>" }
       ],
       "incoming": [
         { "id": "gm_1", "isDraft": true, "draftId": "gd_1",
           "processedHtml": "<p>fresh server body</p>" }
       ],
       "merged": [
         { "id": "gm_1", "isDraft": true, "draftId": "gd_1",
           "draftSessionId": "0c5946...", "userEdited": false,
           "processedHtml": "<p>fresh server body</p>" }
       ]
     }
     ```
   - Data after this step (Case C, unsaved local draft, no server counterpart):
     ```json
     {
       "existing": [
         { "id": "temp-draft-1700...", "isDraft": true, "draftId": null,
           "draftSessionId": "0c5946...", "userEdited": true }
       ],
       "incoming": [],
       "merged": [
         { "id": "temp-draft-1700...", "isDraft": true, "draftId": null,
           "draftSessionId": "0c5946...", "userEdited": true }
       ]
     }
     ```

5. **`setThreadData` and `batchSetThreadData` call the new helper** — in [threadSlice.ts:626](apps/mail/modules/threads/threadList/store/threadSlice.ts) and [threadSlice.ts:787](apps/mail/modules/threads/threadList/store/threadSlice.ts), replace the current merge block with a single `mergeDraftsByUserEdited` call. Keep the existing "skip update if isSame" guard but compare drafts by `(draftSessionId, draftId, userEdited, processedHtml)` set equality only — no more `didDraftSetChange` helper.
   - Receives: `existing.messages`, `data.messages`
   - Calls: `mergeDraftsByUserEdited`
   - Data after this step:
     ```json
     {
       "threadData[threadId].messages": "merged result identical between setThreadData and batchSetThreadData"
     }
     ```

6. **Collapse `getCurrentDraftInfo` to a single `draftSessionId` lookup** — at [email-composer.tsx:789](apps/mail/modules/drafting/components/email-composer.tsx), remove `resolveDraftIdentity`, `selectFallbackExistingDraft`, and the "newest provider draft" rebind. The function becomes: find the draft row where `m.draftSessionId === draftSessionId` (looking under `threadId` first, then under `draftSessionId` as the key for unsaved drafts). Return `{ draftId, messageId, emailHeaderMessageId, currentThreadId, draftSessionId }`.
   - Receives: `draftSessionId`, `threadId`, `emailHeaderMessageIdProp`
   - Calls: `getThreadData`
   - Data after this step:
     ```json
     {
       "lookup": "draftSessionId-only",
       "result": { "draftId": "gd_1", "messageId": "gm_1", "currentThreadId": "thread_123" }
     }
     ```

7. **`saveDraft` keeps `draftId` in lockstep with the server response** — at [email-composer.tsx:986](apps/mail/modules/drafting/components/email-composer.tsx), drop the save generation tracker. The optimistic write stays. After `createDraft` returns, find the row by `draftSessionId` and overwrite `id`, `draftId`, `threadId` from the response — unconditionally. If the response's `draftId` differs from what we sent, this single write reconciles it so the next save targets the right Gmail draft.
   - Receives: form/editor state
   - Calls: `setThreadData`, `setQueryData`, `createDraft` (`trpc.drafts.create`)
   - Data after this step (response carries a new `draftId`):
     ```json
     {
       "rowBefore": { "draftSessionId": "0c5946...", "draftId": "gd_1" },
       "createDraftRequest": { "draftId": "gd_1", "draftSessionId": "0c5946..." },
       "createDraftResponse": { "id": "gd_2", "message": { "id": "gm_2", "threadId": "thread_123" } },
       "rowAfter": { "draftSessionId": "0c5946...", "draftId": "gd_2", "id": "gm_2" }
     }
     ```

8. **`proceedWithSend` drops the send generation guard** — at [email-composer.tsx:958](apps/mail/modules/drafting/components/email-composer.tsx), keep the existing structure (optimistic send, await `savePromiseRef.current`, `mail.send`, invalidate `mail.get`) but remove `generationTrackerRef.current.begin('send')`, `isCurrentGeneration(sendGeneration)`, and the matching `console.debug` lines. Operation state (`operationRef.current`) already serializes save/send for a single composer, so a counter is redundant.
   - Receives: `shouldArchive`
   - Calls: `optimisticSendDraft`, `sendEmail`, `queryClient.invalidateQueries(trpc.mail.get.queryKey({ id: effectiveThreadId }))`
   - Data after this step:
     ```json
     {
       "store": { "draftOptimisticallyRemoved": true },
       "afterMailSend": "mail.get(effectiveThreadId) invalidated"
     }
     ```

9. **`handleDeleteDraft` matches origin/staging** — at [email-composer.tsx:1633](apps/mail/modules/drafting/components/email-composer.tsx), simplify back to: read `{ draftId, messageId, emailHeaderMessageId, draftSessionId }` from `getCurrentDraftInfo`; if neither `draftId` nor `draftSessionId` exists, close. Otherwise `removeMessageFromThread`, update query cache, call `deleteDraftMutation`. No generation begin/check, no "deleting" guard. The existing `operationRef` short-circuit in `saveDraft` already blocks autosave during delete.
   - Receives: `getCurrentDraftInfo()` result
   - Calls: `removeMessageFromThread`, `setQueryData`, `deleteDraftMutation`
   - Data after this step:
     ```json
     {
       "store": { "draftRemoved": true },
       "deleteDraftRequest": { "draftId": "gd_2", "threadId": "thread_123" }
     }
     ```

10. **Delete the generation tracker module** — remove [email-composer-generation.ts](apps/mail/modules/drafting/components/email-composer-generation.ts) and all `import { createComposerGenerationTracker }` references. Delete `generationTrackerRef`, `isCurrentGeneration`, and every `console.debug('[email-composer:generation]', ...)` call.
    - Receives: nothing
    - Calls: nothing
    - Data after this step:
      ```json
      { "generationGuardSurfaceArea": "removed" }
      ```

11. **Delete `markDraftAsLocallyOwned` and `localSessionId` from the slice** — in [threadSlice.ts:1941](apps/mail/modules/threads/threadList/store/threadSlice.ts) and [threadSlice.ts:510](apps/mail/modules/threads/threadList/store/threadSlice.ts), replace with `markDraftAsUserEdited(threadId, draftSessionId)` which sets `userEdited: true` on the matching row. Remove `localSessionId` field and every reader (`m.localSessionId ?? m.draftSessionId` collapses to `m.draftSessionId`).
    - Receives: `{ threadId, draftSessionId }`
    - Calls: `set(...)`
    - Data after this step:
      ```json
      {
        "draftRow": { "draftSessionId": "0c5946...", "userEdited": true }
      }
      ```

12. **Keep server APIs unchanged** — no changes to [drafts.ts:32](apps/server/src/trpc/routes/drafts.ts), [mail.ts:953](apps/server/src/trpc/routes/mail.ts), [send.ts:162](apps/server/src/services/mail/send/send.ts), or [google.ts:1790](apps/server/src/lib/driver/google.ts). The server already echoes `draftId` and `message.id`; the client just trusts them.
    - Data after this step:
      ```json
      { "scope": "client-only" }
      ```

## 4) Implementation phases

### Phase 1 — Type shape + new draft creation paths

**Goal:** `ParsedMessage` carries `userEdited`, every locally-created draft is stamped `userEdited: true` at the moment the composer is opened. Compiles and the app runs (old merge still in place behind the type change).

- [x] Add `userEdited?: boolean` to `ParsedMessage` at [threadSlice.ts:51](apps/mail/modules/threads/threadList/store/threadSlice.ts).
- [x] Set `userEdited: true` on the new draft row in `openDraftWithBody` at [draftSlice.ts:340](apps/mail/modules/drafting/store/draftSlice.ts). Also covered `openNewEmail`, `openNewEmailTo`, `openNewEmailWithContent`, and `createReplyDraft` (all user-initiated composer creations).
- [x] Set `userEdited: true` on the new draft row in `injectDraftIntoThread` at [draftSlice.ts:389](apps/mail/modules/drafting/store/draftSlice.ts).
- [x] Add `markDraftAsUserEdited(threadId, draftSessionId)` action to `threadSlice.ts` (set `userEdited: true` on matching draft row; no-op if none found or already true).
- [x] Wire `markDraftAsUserEdited` into the existing `hasUnsavedChanges` effect at [email-composer.tsx:884](apps/mail/modules/drafting/components/email-composer.tsx) so the first editor change on an existing draft flips the flag.

**Tests:**

- [x] Add `apps/mail/modules/drafting/store/__tests__/draftSlice-user-edited.test.ts` asserting `openDraftWithBody` and `injectDraftIntoThread` emit drafts with `userEdited === true`.
- [x] Add `apps/mail/modules/threads/threadList/store/__tests__/markDraftAsUserEdited.test.ts` asserting idempotent flip from `undefined`/`false` to `true`.
- [x] Run `pnpm --filter @zero/mail test modules/drafting/store/__tests__/draftSlice-user-edited modules/threads/threadList/store/__tests__/markDraftAsUserEdited`.

### Phase 2 — Replace identity merge with `userEdited` merge

**Goal:** `setThreadData` and `batchSetThreadData` share one rule: `userEdited` drafts win. Delete all helpers introduced in the previous branch.

- [x] Add `mergeDraftsByUserEdited(existingMessages, incomingMessages)` in [threadSlice.ts](apps/mail/modules/threads/threadList/store/threadSlice.ts) per Section 3.2 step 4.
- [x] Replace the merge block inside `setThreadData` at [threadSlice.ts:626](apps/mail/modules/threads/threadList/store/threadSlice.ts) with a single call to `mergeDraftsByUserEdited`.
- [x] Replace the merge block inside `batchSetThreadData` at [threadSlice.ts:787](apps/mail/modules/threads/threadList/store/threadSlice.ts) with the same call.
- [x] Delete `getDraftIdentity`, `doDraftsReferToSameEntity`, `hasLocalDraftOwnership`, `didDraftSetChange`, and `mergeThreadMessagesWithDraftIdentity` from `threadSlice.ts`. Added a smaller `areDraftSetsEqual` helper for the isSame fast-path.
- [x] Delete `markDraftAsLocallyOwned` action and its declaration in [threadSlice.ts:510](apps/mail/modules/threads/threadList/store/threadSlice.ts), [threadSlice.ts:1941](apps/mail/modules/threads/threadList/store/threadSlice.ts).
- [x] Remove the `localSessionId` field from `ParsedMessage` at [threadSlice.ts:51](apps/mail/modules/threads/threadList/store/threadSlice.ts) and from `draftSlice.ts` factories at [draftSlice.ts:344](apps/mail/modules/drafting/store/draftSlice.ts), [draftSlice.ts:393](apps/mail/modules/drafting/store/draftSlice.ts).
- [x] Update every remaining reader of `localSessionId` (grep across `apps/mail`) to use `draftSessionId` only.

**Tests:**

- [x] Add `apps/mail/modules/threads/threadList/store/__tests__/mergeDraftsByUserEdited.test.ts` covering Case A (user-edited local wins), Case B (server wins when not edited), Case C (unsaved local survives), and Case D (set vs batch parity).
- [x] Delete `apps/mail/modules/threads/threadList/store/__tests__/thread-slice-draft-merge.test.ts` and `apps/mail/modules/threads/threadList/store/__tests__/thread-slice-draft-identity.test.ts`.
- [x] Run `pnpm --filter @zero/mail test modules/threads/threadList/store/__tests__/mergeDraftsByUserEdited`. Also re-ran the full mail jest suite: the 6 remaining failures (handleNavigateToTask, messagesSlice.basic/thread, DraftReviewPanel, email-composer-send, agentContextSlice) all pre-date this branch and are unrelated to draft ownership.

### Phase 3 — Strip generation guards from the composer

**Goal:** `email-composer.tsx` matches origin/staging structurally — no save/send/delete generation tracker, single-key draft lookup, server `draftId` write-back unconditional.

- [x] Delete [apps/mail/modules/drafting/components/email-composer-generation.ts](apps/mail/modules/drafting/components/email-composer-generation.ts).
- [x] Remove the `createComposerGenerationTracker` import and `generationTrackerRef`/`isCurrentGeneration` declarations near [email-composer.tsx:64](apps/mail/modules/drafting/components/email-composer.tsx) and [email-composer.tsx:734](apps/mail/modules/drafting/components/email-composer.tsx).
- [x] Collapse `getCurrentDraftInfo` at [email-composer.tsx:789](apps/mail/modules/drafting/components/email-composer.tsx) to a single `draftSessionId` lookup; remove `resolveDraftIdentity`, `selectFallbackExistingDraft`, and `localDraftSessionIdRef`.
- [x] In `saveDraft` at [email-composer.tsx:986](apps/mail/modules/drafting/components/email-composer.tsx): remove every `generationTrackerRef.current.begin('save')` / `isCurrentGeneration(saveGeneration)` call. Keep the optimistic write and the server-id write-back; ensure the write-back always overwrites `draftId` from the response so a server-side id change reconciles.
- [x] In `proceedWithSend` at [email-composer.tsx:958](apps/mail/modules/drafting/components/email-composer.tsx): remove every `sendGeneration` / `isCurrentGeneration` / `[email-composer:generation]` log. Keep the `mail.get` invalidation at [email-composer.tsx:950](apps/mail/modules/drafting/components/email-composer.tsx).
- [x] In `handleDeleteDraft` at [email-composer.tsx:1633](apps/mail/modules/drafting/components/email-composer.tsx): drop the `generationTrackerRef.current.begin('delete')` call and any related guards; restore origin/staging shape.

**Tests:**

- [x] Add `apps/mail/modules/drafting/components/__tests__/email-composer-draft-id-reconcile.test.tsx` covering both the refresh no-duplicate regression and the draftId reconcile flow. Folded into one file because both invariants exercise the same store-level rules (the second test caught a real bug in `mergeDraftsByUserEdited` where composer self-writes were being eaten — fixed by preferring incoming when `userEdited === true`).
- [x] Delete `apps/mail/modules/drafting/components/__tests__/email-composer-save-send-generation-guards.test.tsx`.
- [x] Run `pnpm --filter @zero/mail test modules/drafting/components/__tests__/email-composer-draft-id-reconcile`.

### Phase 4 — Race coverage + typecheck + docs cleanup

**Goal:** End-to-end test confirms prefetch + foreground + save callback ordering produces no duplicate or dropped drafts, and the old design doc is retired.

- [x] Update `apps/mail/tests/modules/mail/thread-data-sync-race.test.tsx` so the locally-edited draft (now identified by `userEdited: true`) survives interleaved foreground + prefetch. The old "stale save callback via generation tracker" assertion was rewritten as a pure "userEdited survives interleaved server arrivals" test, since no generation tracker exists anymore.
- [x] Delete `docs/design/draft-ownership-and-mail-get-reconciliation.md` (superseded).
- [x] Run `pnpm --filter @zero/mail types` (the actual script name; the doc said `typecheck`). The 5 errors in changed files (composer line 431/653/1008/2049, threadSlice line 1851) all pre-date Phase 4. The wider repo has many other pre-existing errors (server/, calendar, AOP, jest globals). No new errors introduced by this branch.
- [x] Run `pnpm --filter @zero/mail test tests/modules/mail/thread-data-sync-race`.