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