Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(drafts): a first save could adopt a draft that was never ours

merged#2655CedarCopilot

CedarCopilot wants to merge 1 commit into staging from fix/dm-container-kind

Live on prod, no production signal yetTimeline and evidence
  1. Opened
    Sep 2, 2026, 12:58 AM
  2. Sep 2, 2026, 1:03 AM
  3. Merged
    Sep 2, 2026, 1:09 AM
  4. Live on prod
    Sep 2, 2026, 1:09 AM
  5. Observed 7 hours
    Sep 2, 2026, 1:09 AM
  6. Unobserved

    Live on prod, no production signal yet

    Sep 2, 2026, 1:09 AM
  7. Pipelines steady after this deploy
    Sep 2, 2026, 1:09 AM

Behaviors Libra is checking

A first save with no client-supplied draft ID creates a new draft instead of recovering and overwriting an unrelated draft already present on the thread.Not checked
prod
The duplicate-draft sweep treats drafts as deletable duplicates only when their normalized bodies, subjects, and recipient sets all match, leaving same-body drafts with different recipients or subjects untouched.Not checked
prod

Libra has verdicts on 0 of 2 tracked behaviors on prod; 2 are still being checked. Libra checks hourly for 3 days after each deploy.

Follow-up to #2653, which merged before this landed.

The bug that merged

The thread-adoption rung added in #2653 ran before every create, including the first save of a brand-new compose , which arrives with no draft id at all. On a thread that already holds one draft (the user's own half-written reply, or an agent's), that first save adopted the stranger's draft and overwrote it.

The length === 1 guard in #2653 was written against the wrong risk. It reasoned about two drafts on a thread, where you cannot tell which is yours, and never asked the prior question: whether this session had a draft here at all. Thread membership is evidence a draft is ours only once the client claimed to own one; on its own it is a coincidence, and acting on it destroys writing , the exact harm the guard existed to prevent.

Caught by Greptile on #2653 (P1). It is right, and this is currently live on staging.

What changed

  • google.ts , the recovery rung is gated on the client having supplied a draft id. A save with no id is a first save and creates, as it always should have.
  • Regression test that fails against the merged code: a first save onto a thread holding one unrelated draft must create, not adopt.

Two other findings from the same review:

  • sweep-duplicate-drafts.ts , the sweep compared bodies alone, so the same words to a different recipient or under a different subject read as a duplicate. It now compares recipients and subject too, and names attachments as the blind spot it cannot see through ParsedDraft.
  • draftIdResolvedResponseProcessor.ts , the read side now narrows the stream payload instead of asserting through it. The write side keeps one assertion, because updateMessage is typed to the base Message union and cannot name a custom variant's fields; it carries the justification comment CLAUDE.md requires.

Not addressed

ConversationSearchCommandBar.tsx:346 , Greptile P1 (security): selecting a non-shared AOP and searching the organization may surface conversations past the private filter. That came from b19e95a6d, not this work, and also merged in #2653. Flagging rather than fixing blind , it wants its own change and someone who knows that filter's intent.

Test plan

  • pnpm run autofix:local green on the fix commit (404 Jest suites / 3,916 tests; 856 Vitest files)
  • Driver + send suites after merging current staging: 29 files / 186 tests

šŸ¤– Generated with Claude Code

Greptile Summary

The PR prevents first saves from adopting unrelated drafts while making duplicate-draft cleanup more conservative and stream-event validation more type-safe.

  • Gates thread-based draft recovery on the client having supplied a draft ID.
  • Adds regression coverage for first saves on threads containing an unrelated draft.
  • Includes recipients and subject when identifying deletable duplicate drafts.
  • Narrows draft-resolution stream payloads before updating draft-card identity.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete changed-code failure remaining on current production paths.

The recovery gate prevents first-save overwrites, all production callers normalize placeholder IDs before reaching the driver, duplicate deletion eligibility is narrowed, and the response-processor changes preserve existing validated behavior.

Important Files Changed

FilenameOverview
apps/server/src/lib/driver/google.tsCorrectly prevents no-ID first saves from entering sole-thread-draft recovery while preserving recovery for failed updates of client-claimed drafts.
apps/server/src/lib/driver/tests/google-create-draft-404-recovery.test.tsAdds focused regression coverage proving a first save creates rather than overwrites an unrelated draft.
apps/server/src/scripts/sweep-duplicate-drafts.tsNarrows destructive duplicate classification to matc
Show production surfaces and changed-file mapping

Production surfaces

Libra has not measured any production surfaces for this change yet.

Changed files → surfaces

  • apps/mail/modules/cedar-os/src/store/agentConnection/responseProcessors/draftIdResolvedResponseProcessor.tsno production surface mapped
  • apps/server/src/lib/driver/__tests__/google-create-draft-404-recovery.test.tsno production surface mapped
  • apps/server/src/lib/driver/google.tsno production surface mapped
  • apps/server/src/scripts/sweep-duplicate-drafts.tsno production surface mapped