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`.