DESIGN-duplicate-draft-reconciliation.md25.4 KBView on GitHub
# Duplicate draft reconciliation — closing the draft identity gap

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

We want a thread to show exactly one composer for one logical draft, no matter how `mail.get` and the autosave interleave. Today a brand-new reply exists locally under a client UUID (`draftSessionId`) with `draftId: null` until the first `drafts.create` response writes the Gmail id back, while the same draft arriving from `mail.get` carries the Gmail `draftId` but no `draftSessionId` — so during that window `mergeDraftsByUserEdited` can match the two rows by neither key and appends the server twin as a second draft, which then persists forever because the local row is `userEdited` (protected) and the server row is treated as truth. The future state closes the identity gap from both ends: the merge gains an explicit "adoption" rule that binds an unmatched server draft to the single in-flight unsaved local draft and stamps the server ids onto it, and the server begins durably recording `draftSessionId ↔ draftId` and echoing `draftSessionId` on `mail.get` draft rows so the primary match key is always present and the heuristic becomes a backstop rather than the mechanism.

## 2) Present state

### 2.1 Architecture diagram

```text
  [user types in composer]
            |
            v
  [autosave 1s debounce]  email-composer.tsx:1649
            |
            v
  [getCurrentDraftInfo] --> draftId: null  (never saved yet)
            |
            v
  [optimistic setThreadData]  local row { draftSessionId: A-uuid, draftId: null, userEdited: true }
            |
            v
  [drafts.create { draftSessionId: A-uuid, draftId: null }]
            |                                   |
            |                                   v
            |                       [send.ts createDraft]
            |                       draftIdBySessionKey (in-memory, per-pod)
            |                                   |
            |                                   v
            |                       [google.ts createDraft STEP 3: CREATE] --> gd_X
            |                                   |
            |                                   v
            |                       [syncThreadViaProvider] --> mirror now has gd_X
            |
            |    <<<<<<<<<<<< RACE WINDOW >>>>>>>>>>>>
            |    any mail.get resolving here returns gd_X with NO draftSessionId
            |    while the local row still has draftId: null
            |
            v                                    v
  [write-back stamps draftId]        [ThreadDataSync -> setThreadData]
  email-composer.tsx:962                thread-data-sync.tsx:43
                                                 |
                                                 v
                                    [mergeDraftsByUserEdited]  threadSlice.ts:787
                                       findExistingMatch:
                                         tier 1 by draftSessionId -> incoming has none  MISS
                                         tier 2 by draftId        -> local has none     MISS
                                                 |
                                                 v
                                    server row appended + local row appended
                                                 |
                                                 v
                                    [ThreadDraftSection] drafts.length === 2
                                          -> DraftTabs render -> TWO COMPOSERS
```

### 2.2 Step-by-step walkthrough

1. **Reply draft is created locally** — `createReplyDraft` at [threadSlice.ts:2159](apps/mail/modules/threads/threadList/store/threadSlice.ts). Reuses an existing draft if the thread already has one ([threadSlice.ts:2231](apps/mail/modules/threads/threadList/store/threadSlice.ts)); otherwise mints a fresh UUID at [threadSlice.ts:2287](apps/mail/modules/threads/threadList/store/threadSlice.ts).
   - Receives: `{ threadId, messageId, replyType, activeConnectionEmail, message }`
   - Calls: `set(...)` to splice the new row into `threadData[threadId].messages`
   - Data after this step:
     ```json
     { "id": "temp-draft-1718000000000", "draftSessionId": "A-uuid",
       "draftId": null, "userEdited": true, "isDraft": true, "threadId": "19d42810a7c5587f" }
     ```

2. **Composer mounts with a derived stable id** — `getStableId` at [thread-draft-section.tsx:40](apps/mail/modules/threads/thread/components/thread-draft-section.tsx) resolves `draftSessionId ?? draftId ?? id`, and the composer key is chosen at [thread-draft-section.tsx:161](apps/mail/modules/threads/thread/components/thread-draft-section.tsx).
   - Receives: the selected draft row
   - Calls: `DraftComposer` → `EmailComposer` with `draftSessionId={stableId}`
   - Data after this step:
     ```json
     { "composerKey": "A-uuid", "draftSessionIdProp": "A-uuid" }
     ```
   - Branch: for a **server-loaded** draft the row has no `draftSessionId`, so the prop is actually the Gmail `draftId` (or message `id`).

3. **First editor change flags the row** — effect at [email-composer.tsx:786](apps/mail/modules/drafting/components/email-composer.tsx) calls `markDraftAsUserEdited` at [threadSlice.ts:2144](apps/mail/modules/threads/threadList/store/threadSlice.ts), which matches **only** `m.draftSessionId === draftSessionId` ([threadSlice.ts:2149](apps/mail/modules/threads/threadList/store/threadSlice.ts)).
   - Receives: `{ threadId, draftSessionId }`
   - Data after this step:
     ```json
     { "draftSessionId": "A-uuid", "userEdited": true }
     ```
   - Branch (**defect**): for a server-loaded draft the row's `draftSessionId` is `undefined` while the prop is the `draftId`, so no row matches and `userEdited` is **never set** — leaving those edits unprotected from an incoming `mail.get` body.

4. **Autosave fires after 1s idle** — effect at [email-composer.tsx:1649](apps/mail/modules/drafting/components/email-composer.tsx), guarded by `operation === 'idle'`.
   - Calls: `saveDraft` at [email-composer.tsx:842](apps/mail/modules/drafting/components/email-composer.tsx)

5. **`getCurrentDraftInfo` resolves the current ids** — at [email-composer.tsx:749](apps/mail/modules/drafting/components/email-composer.tsx). Matches by any of the three identifiers ([email-composer.tsx:757](apps/mail/modules/drafting/components/email-composer.tsx)).
   - Data after this step:
     ```json
     { "draftId": undefined, "messageId": "temp-draft-1718000000000", "currentThreadId": "19d42810a7c5587f" }
     ```

6. **Optimistic write** — at [email-composer.tsx:887](apps/mail/modules/drafting/components/email-composer.tsx). Finds the row by `draftSessionId` ([email-composer.tsx:917](apps/mail/modules/drafting/components/email-composer.tsx)), replacing it or **pushing** if absent, then `setThreadData` + `setQueryData`.
   - Data after this step:
     ```json
     { "id": "A-uuid", "draftSessionId": "A-uuid", "draftId": null, "userEdited": true }
     ```

7. **`drafts.create` reaches the server** — route at [drafts.ts:36](apps/server/src/trpc/routes/drafts.ts) → `createDraft` at [send.ts:164](apps/server/src/services/mail/send/send.ts). The session→draft mapping is consulted and written to a **process-local `Map`** at [send.ts:18](apps/server/src/services/mail/send/send.ts) and [send.ts:192](apps/server/src/services/mail/send/send.ts).
   - Data after this step:
     ```json
     { "sessionKey": "conn_1:A-uuid", "knownDraftId": null, "reusedDraftFromSession": false }
     ```

8. **Gmail creates a new draft** — `createDraft` in the driver tries update-by-`draftId`, then lookup-by-`emailHeaderMessageId`, then falls through to CREATE at [google.ts:3065](apps/server/src/lib/driver/google.ts). `syncThreadViaProvider` then mirrors the thread at [send.ts:211](apps/server/src/services/mail/send/send.ts).
   - Data after this step:
     ```json
     { "id": "gd_X", "message": { "id": "gm_msgX", "threadId": "19d42810a7c5587f" } }
     ```

9. **A `mail.get` resolves inside the race window** — `ThreadDataSync` pipes every result into `setThreadData` at [thread-data-sync.tsx:43](apps/mail/modules/threads/thread/components/thread-data-sync.tsx) (`staleTime: 30s`, `refetchOnMount: true`). Draft rows are built by `enrichDraftMessages` at [google.ts:1243](apps/server/src/lib/driver/google.ts), which attaches `draftId` but has no `draftSessionId` to attach.
   - Data after this step:
     ```json
     { "id": "gm_msgX", "draftId": "gd_X", "draftSessionId": null, "userEdited": false, "isDraft": true }
     ```

10. **The merge fails to bind the pair** — `mergeDraftsByUserEdited` at [threadSlice.ts:787](apps/mail/modules/threads/threadList/store/threadSlice.ts); `findExistingMatch` at [threadSlice.ts:794](apps/mail/modules/threads/threadList/store/threadSlice.ts); identity key at [threadSlice.ts:680](apps/mail/modules/threads/threadList/store/threadSlice.ts).
    - tier 1 (`draftSessionId`): incoming has none → skip
    - tier 2 (`draftId`): no local row carries `gd_X` yet → miss
    - unmatched incoming is pushed (`key=[redacted]`); the unmatched local `userEdited` row is appended by the tail loop at [threadSlice.ts:842](apps/mail/modules/threads/threadList/store/threadSlice.ts) (`key=[redacted]`)
    - Data after this step:
      ```json
      [ { "draftId": "gd_X", "draftSessionId": null,   "userEdited": false },
        { "draftId": null,   "draftSessionId": "A-uuid", "userEdited": true } ]
      ```

11. **Write-back lands too late** — at [email-composer.tsx:962](apps/mail/modules/drafting/components/email-composer.tsx) the row matched by `draftSessionId` gets `draftId: "gd_X"`. Both rows now carry `gd_X`, but their identity keys differ (`A-uuid` vs `gd_X`) and the local one is protected, so every later merge keeps both.

12. **Two composers render** — `ThreadDraftSection` filters drafts at [thread-draft-section.tsx:55](apps/mail/modules/threads/thread/components/thread-draft-section.tsx) and renders `DraftTabs` once `drafts.length > 1` at [thread-draft-section.tsx:170](apps/mail/modules/threads/thread/components/thread-draft-section.tsx).

## 3) Designed state

### 3.1 Architecture diagram

```text
  [drafts.create { draftSessionId: A-uuid }]
            |
            v
  [send.ts createDraft]
     draftIdBySessionKey (in-memory, fast path)
     putDraftSession(connectionId, gd_X, A-uuid) ---> [KV: draft-session namespace]   (Phase 3)
            |                                                     |
            v                                                     |
  [google.ts createDraft] --> gd_X                                |
            |                                                     |
            v                                                     |
  [mail.get -> enrichDraftMessages] <----- getDraftSession(gd_X) -+                   (Phase 4)
            |
            v
  incoming row { draftId: gd_X, draftSessionId: A-uuid }   <-- key now ALWAYS present
            |
            v
  [ThreadDataSync -> setThreadData -> mergeDraftsByUserEdited]
       findExistingMatch:
         tier 1 by draftSessionId  -> HIT (Phase 4 makes this the normal path)
         tier 2 by draftId         -> HIT once write-back has run
         tier 3 ADOPTION           -> exactly one unsaved userEdited local row  (Phase 1 backstop)
            |
            v
  keep local row (userEdited content) + stamp server ids onto it
            |
            v
  [ThreadDraftSection] drafts.length === 1  -> ONE COMPOSER
```

### 3.2 Step-by-step walkthrough

1. **`findExistingMatch` gains a tier-3 adoption rule** — in `mergeDraftsByUserEdited` at [threadSlice.ts:794](apps/mail/modules/threads/threadList/store/threadSlice.ts). When tiers 1 and 2 miss and the incoming row carries a `draftId`, bind it to the sole local draft that is `userEdited === true` with no `draftId`. The `length === 1` guard makes it refuse to guess when ambiguous.
   - Receives: `incoming` draft row, `existingDrafts`
   - Calls: nothing — pure
   - Data after this step:
     ```json
     { "tier": 3, "candidates": 1, "boundTo": { "draftSessionId": "A-uuid", "draftId": null } }
     ```
   - Branch: `candidates !== 1` → return `undefined` (today's behavior; transient duplicate preferred over a wrong bind).

2. **The kept local row adopts the server ids** — in the `existingMatch?.userEdited === true` branch at [threadSlice.ts:825](apps/mail/modules/threads/threadList/store/threadSlice.ts). Keep local content and `userEdited`, but carry `draftId` (and `id` when the local row was never saved) from the incoming row so the pair matches by tier 2 forever after.
   - Data after this step:
     ```json
     { "id": "gm_msgX", "draftSessionId": "A-uuid", "draftId": "gd_X", "userEdited": true }
     ```

3. **`markDraftAsUserEdited` matches on all three identifiers** — at [threadSlice.ts:2144](apps/mail/modules/threads/threadList/store/threadSlice.ts), mirroring `getCurrentDraftInfo` at [email-composer.tsx:757](apps/mail/modules/drafting/components/email-composer.tsx) so server-loaded drafts get flagged on first edit.
   - Receives: `{ threadId, draftSessionId }` (may hold a `draftId` or message `id`)
   - Data after this step:
     ```json
     { "id": "gm_msgX", "draftId": "gd_X", "draftSessionId": null, "userEdited": true }
     ```

4. **`putDraftSession` records the pairing durably** — new helper in [send.ts](apps/server/src/services/mail/send/send.ts), backed by `createKVProxy` at [aws-runtime-bindings.ts:528](apps/server/src/container/aws-runtime-bindings.ts). Written alongside the existing in-memory `Map` write at [send.ts:199](apps/server/src/services/mail/send/send.ts) so the fast path is unchanged.
   - Receives: `{ connectionId, draftId, draftSessionId }`
   - Data after this step:
     ```json
     { "key": "draft-session:conn_1:gd_X", "value": { "draftSessionId": "A-uuid", "updatedAt": "2026-07-20T00:00:00.000Z" } }
     ```

5. **`getDraftSession` reads it back on the create path** — replaces the pod-local cache miss at [send.ts:195](apps/server/src/services/mail/send/send.ts), so a save that lands on a different pod still updates the existing Gmail draft instead of creating a second one.
   - Data after this step:
     ```json
     { "sessionKey": "conn_1:A-uuid", "knownDraftId": "gd_X", "reusedDraftFromSession": true }
     ```

6. **`enrichDraftMessages` stamps `draftSessionId` onto draft rows** — at [google.ts:1243](apps/server/src/lib/driver/google.ts). It currently early-returns when `draftId` is already present ([google.ts:1252](apps/server/src/lib/driver/google.ts)); that guard must be restructured so the session lookup still runs.
   - Receives: `ParsedMessage[]`
   - Calls: `getDraftSession(connectionId, draftId)`
   - Data after this step:
     ```json
     { "id": "gm_msgX", "draftId": "gd_X", "draftSessionId": "A-uuid", "isDraft": true }
     ```

7. **The merge matches on tier 1 in the normal case** — [threadSlice.ts:795](apps/mail/modules/threads/threadList/store/threadSlice.ts) now hits on the first try; tier 3 remains only as a backstop for drafts created before the mapping existed.
   - Data after this step:
     ```json
     { "merged": [ { "draftSessionId": "A-uuid", "draftId": "gd_X", "userEdited": true } ] }
     ```

### 3.3 Schema

No database tables change. One frontend type is unchanged in shape but gains a new invariant, one server input schema is unchanged, and one new KV record is introduced.

Full schema:

```ts
// apps/mail/modules/threads/threadList/store/threadSlice.ts — UNCHANGED shape.
// Listed in full because the draft-identity trio is the subject of this design.
interface ParsedMessage {
  id: string;                          // Gmail message id, or temp-draft-<ts> before first save
  draftId?: string;                    // Gmail stable draft id; null until first save write-back
  draftSessionId?: string;             // client UUID; NEW INVARIANT: server now echoes it (Phase 4)
  userEdited?: boolean;                // local-ownership flag; server never sets it
  connectionId?: string;
  snippet?: string;
  subject: string;
  tags: Label[];
  sender: Sender;
  to: Sender[];
  cc: Sender[] | null;
  bcc: Sender[] | null;
  tls: boolean;
  listUnsubscribe?: string;
  listUnsubscribePost?: string;
  receivedOn: string;
  unread: boolean;
  processedHtml: string;
  blobUrl: string;
  references?: string;
  inReplyTo?: string;
  replyTo?: string;
  messageId?: string;
  emailHeaderMessageId?: string;
  threadId?: string;
  isDraft?: boolean;
  attachments?: Attachment[];
  draftFiles?: File[];
  additionalLabelNames?: string[];
}

// apps/server/src/lib/schemas.ts — UNCHANGED. Shown because draftSessionId enters the server here.
const createDraftData = z.object({
  to: z.string(),
  cc: z.string().optional(),
  bcc: z.string().optional(),
  subject: z.string(),
  message: z.string(),
  attachments: z.array(serializedFileSchema).optional(),
  draftId: z.string().nullable().optional(),
  draftSessionId: z.string().optional(),
  emailHeaderMessageId: z.string().nullable().optional(),
  id: z.string().nullable().optional(),
  threadId: z.string().nullable(),
  fromEmail: z.string().nullable(),
  conversationId: z.string().optional(),
  isCedarMailDraft: z.boolean().optional().default(false),
  taskType: z.string().optional(),
});

// NEW — apps/server/src/services/mail/send/send.ts, stored via createKVProxy('draft-session').
// Two records per pairing so both directions resolve in one read.
interface DraftSessionRecord {
  draftSessionId: string;              // NEW - client UUID
  draftId: string;                     // NEW - Gmail stable draft id
  connectionId: string;                // NEW - scopes the pairing to one mailbox
  updatedAt: string;                   // NEW - ISO8601, for TTL / staleness pruning
}

// Key shapes (both point at the same logical pairing):
//   `draft-session:${connectionId}:${draftId}`         -> DraftSessionRecord   (read by enrichDraftMessages)
//   `draft-session-rev:${connectionId}:${draftSessionId}` -> DraftSessionRecord (read by createDraft)
```

Relationship diagram:

```text
   ParsedMessage (draft row, client)          DraftSessionRecord (KV)              Gmail
  +-------------------------------+        +--------------------------+      +---------------+
  | id            (gm_msgX)       |        | connectionId  (conn_1)   |      | draft id gd_X |
  | draftId       (gd_X)  --------|--FK--> | draftId       (gd_X)     |--1:1-| message gm_msgX|
  | draftSessionId(A-uuid)--------|--FK--> | draftSessionId(A-uuid)   |      | threadId 19d4… |
  | userEdited    (true)          |        | updatedAt                |      +---------------+
  | isDraft       (true)          |        +--------------------------+
  | threadId      (19d42810…)     |               ▲          ▲
  +-------------------------------+               |          |
              │                        written by │          │ read by
              │ N:1                    send.ts    │          │ enrichDraftMessages
              ▼                        createDraft│          │ (google.ts)
      ThreadData.messages[]                       │          │
  +-------------------------------+               │          │
  | id       (threadId)           |               │          │
  | messages ▼ contains ParsedMessage[]           │          │
  | hasDraft (derived)            |          drafts.create   mail.get
  +-------------------------------+

  Cardinality notes:
    ParsedMessage(draft) ──1:1── DraftSessionRecord   (one pairing per Gmail draft)
    ThreadData ──1:N── ParsedMessage                  (the bug makes this 1:2 for one logical draft)
    connectionId ──1:N── DraftSessionRecord           (many drafts per mailbox)
```

## 4) Implementation phases

### Phase 1 — Reproduce and close the merge gap (Option 1)

**Goal:** A `mail.get` landing before the save write-back collapses to a single draft row instead of two.

- [ ] Add a tier-3 adoption branch to `findExistingMatch` in `mergeDraftsByUserEdited` at [threadSlice.ts:794](apps/mail/modules/threads/threadList/store/threadSlice.ts): when tiers 1 and 2 miss and `incoming.draftId` is set, bind to the sole `existingDrafts` entry with `userEdited === true && !draftId`.
- [ ] Guard the adoption with an exact `length === 1` check so an ambiguous set falls back to returning `undefined`.
- [ ] In the `existingMatch?.userEdited === true` branch at [threadSlice.ts:825](apps/mail/modules/threads/threadList/store/threadSlice.ts), carry `draftId` from the incoming row when the local row has none.
- [ ] Carry the incoming `id` onto the kept row only when the local row was never saved (`!existingMatch.draftId`), so saved rows keep their message id.
- [ ] Add a short comment block above the tier-3 rule explaining the race window it exists to close, referencing this doc.

**Tests:**

- [ ] Add to [mergeDraftsByUserEdited.test.ts](apps/mail/modules/threads/threadList/store/__tests__/mergeDraftsByUserEdited.test.ts): local `userEdited` draft with `draftId: undefined` + incoming server draft with `draftId` and no `draftSessionId` merges to exactly **one** row (fails before the fix).
- [ ] Add a case asserting the merged row keeps local `processedHtml`/`userEdited` and adopts `draftId` from the incoming row.
- [ ] Add a case asserting two unsaved `userEdited` drafts in one thread do **not** adopt (ambiguity guard holds; no wrong bind).
- [ ] Add a case asserting a saved local row (`draftId` present) still matches by tier 2 and does not take the incoming `id`.
- [ ] Run `pnpm --filter @zero/mail test modules/threads/threadList/store/__tests__/mergeDraftsByUserEdited`.

### Phase 2 — Flag server-loaded drafts as user-edited

**Goal:** Editing a draft loaded from `mail.get` sets `userEdited`, protecting those edits from server overwrite.

- [ ] Widen the match in `markDraftAsUserEdited` at [threadSlice.ts:2149](apps/mail/modules/threads/threadList/store/threadSlice.ts) to `m.draftSessionId === id || m.draftId === id || m.id === id`, mirroring [email-composer.tsx:757](apps/mail/modules/drafting/components/email-composer.tsx).
- [ ] Keep the idempotent early-return when the row is already `userEdited`.

**Tests:**

- [ ] Extend [markDraftAsUserEdited.test.ts](apps/mail/modules/threads/threadList/store/__tests__/markDraftAsUserEdited.test.ts): a row with no `draftSessionId` is flagged when called with its `draftId` (fails before the fix).
- [ ] Add a case for matching by message `id` when both `draftSessionId` and `draftId` are absent.
- [ ] Add a merge test: a server-loaded draft the user edited is not overwritten by a later `mail.get` body.
- [ ] Run `pnpm --filter @zero/mail test modules/threads/threadList/store/__tests__/markDraftAsUserEdited`.

### Phase 3 — Durable draftSessionId ↔ draftId mapping

**Goal:** The session→draft pairing survives pod restarts and cross-pod routing, eliminating genuinely duplicated Gmail drafts.

- [ ] Add `putDraftSession` / `getDraftSession` helpers in [send.ts](apps/server/src/services/mail/send/send.ts) backed by `createKVProxy` at [aws-runtime-bindings.ts:528](apps/server/src/container/aws-runtime-bindings.ts), writing both the forward and reverse keys from §3.3.
- [ ] Write the pairing after a successful create alongside the existing `Map` write at [send.ts:199](apps/server/src/services/mail/send/send.ts).
- [ ] Fall back to `getDraftSession` when the in-memory lookup misses at [send.ts:195](apps/server/src/services/mail/send/send.ts), keeping the `Map` as the fast path.
- [ ] Add a TTL (or `updatedAt`-based prune) so abandoned session keys do not accumulate.
- [ ] Extend the span attributes at [send.ts:204](apps/server/src/services/mail/send/send.ts) with the mapping source (`memory` / `kv` / `none`) for observability.

**Tests:**

- [ ] Add `apps/server/src/services/mail/send/__tests__/draft-session-mapping.test.ts` asserting a create writes both keys and a subsequent lookup resolves the `draftId`.
- [ ] Add a case simulating a cleared in-memory `Map` (pod restart): the KV fallback still yields `reusedDraftFromSession: true`, so no second Gmail draft is created.
- [ ] Run `pnpm --filter=@zero/server test src/services/mail/send/__tests__/draft-session-mapping`.

### Phase 4 — Echo draftSessionId on mail.get draft rows

**Goal:** Incoming server drafts carry the client UUID, making tier 1 the normal match path and demoting tier 3 to a backstop.

- [ ] Restructure the `draftId`-present early-return in `enrichDraftMessages` at [google.ts:1252](apps/server/src/lib/driver/google.ts) so the session lookup runs even when `draftId` is already known.
- [ ] Stamp `draftSessionId` from `getDraftSession(connectionId, draftId)` onto each draft row in `enrichDraftMessages` at [google.ts:1243](apps/server/src/lib/driver/google.ts).
- [ ] Audit every path that builds thread draft rows (the `enrichDraftMessages` call site at [google.ts:1510](apps/server/src/lib/driver/google.ts) plus any S3-mirror read that bypasses it) and confirm each one stamps the field.
- [ ] Keep the tier-3 adoption rule as a backstop for drafts predating the mapping; add a comment marking it as such.

**Tests:**

- [ ] Add a server test asserting `enrichDraftMessages` attaches `draftSessionId` when a mapping exists and leaves it undefined when it does not.
- [ ] Add a merge test asserting a tier-1 (`draftSessionId`) match wins and never reaches tier 3.
- [ ] Update [thread-data-sync-race.test.tsx](apps/mail/tests/modules/mail/thread-data-sync-race.test.tsx) to cover the full interleave (optimistic → `mail.get` with echoed session id → write-back) yielding one draft.
- [ ] Run `pnpm --filter @zero/mail test tests/modules/mail/thread-data-sync-race` and `pnpm --filter @zero/mail types`.