Skip to content

feat(email-core): guard against caller-replayed duplicate delivery - #209

Merged
stevenobiajulu merged 3 commits into
mainfrom
tweak-duplicate-send-guard-20260907
Sep 8, 2026
Merged

stevenobiajulu merged 3 commits into
mainfrom
tweak-duplicate-send-guard-20260907

Conversation

@stevenobiajulu

Copy link
Copy Markdown
Member

Why

isRetryable(err, 'delivery') === false and the single-attempt dispatch in send_email / reply_to_email / send_draft guarantee that this library never re-issues a send. They guarantee nothing about the caller, and the caller is the one that replays.

An agent whose tool result was lost — a compacted context, a dropped transport, a supervisor restarting the turn, a person retrying a call that looked stuck — re-issues the identical instruction, and the second call is indistinguishable from a first one. The only defence was a sentence of English in each tool description ("do not resend without checking Sent Items"), which is exactly the guard an LLM under retry pressure skips. When it fails it fails silently: the second send succeeds and the recipient gets the message twice.

Provider endpoints take no idempotency key, so this cannot be fixed at the provider boundary. It is fixed one level up.

What

An in-process send ledger keyed by a digest of the effective action inputs — mailbox, recipients, subject, rendered post-truncation body, attachment content hashes, scheduled time, reply parent or draft id. The record is written before dispatch, so the reserved window is exactly the window in which an acknowledgement gets lost, and settled after.

Three refusal codes, because the recovery differs:

Code Meaning What to do
DUPLICATE_SEND_IN_FLIGHT An identical delivery has not returned Wait for the first call
DUPLICATE_SEND_BLOCKED An identical delivery already succeeded Stop — the response carries the original messageId
DUPLICATE_SEND_UNRESOLVED An identical delivery ended ambiguously Check Sent Items before resending

Release is an allowlist, not a denylist. Only codes that prove non-delivery — 4xx-derived, 429, connect-failure, and the Graph paths that fail before the delivery POST — free the fingerprint, so a resend after a rejection is never blocked. Everything else, including a code no provider has shown us yet, is held. A wrongly-held message costs one refused resend and names the override; a wrongly-released one costs a duplicate in somebody's inbox.

On by default, with no embedder wiring. ActionContext.sendLedger exists only so tests can inject an isolated ledger. ActionContext.rateLimiter is the cautionary example — an optional injected interface that no shipped adapter constructs, so the rate limit it documents does not exist in the product.

allow_duplicate: true is the deliberate escape hatch; AGENT_EMAIL_DUPLICATE_SEND_WINDOW_MS tunes the window (default 15 min, 0 disables).

Commits

  1. feat(email-core) — the guard.
  2. fix(email-core) — five defects Codex peer review found in it, each reproduced before being fixed and each now carrying a regression test that fails without the fix: settlement/release addressed the fingerprint rather than the attempt (a slow attempt's late rejection deleted a newer attempt's successful record); a rejected allow_duplicate erased the delivery it was overriding; the process-default ledger keyed on an optional mailboxName, so a second mailbox's first-ever send was refused and handed the first mailbox's message id; a success settling after its reservation was pruned was dropped; size-cap eviction could evict an in-flight reservation. Plus incomplete proven-unsent codes for both shipped providers, and four documentation claims that overstated what the code does — including a "salted digest" that was never implemented, corrected in place rather than quietly deleted.
  3. fix(email-core) — a pre-existing misreport, found during that review and confirmed to predate the guard: an unclassified throw on the scheduled-send path was labelled SCHEDULE_SEND_FAILED, a terminal code, for an outcome that proves nothing of the kind.

Limitation, stated rather than papered over

The ledger is per-process and in memory. It closes the replay window that actually occurs — a retry inside a running server — and does not survive restarting that server. Cross-process durability needs a persisted store and is deliberately not attempted here.

Evidence

All four gates green: build, lint, test:run, check:spec-coverage (290/290). 571 email-core tests (from 546) and 119 provider-gmail tests (from 116).

Two mutation controls, because a green check that could not have gone red proves nothing:

  • Ledger window forced to 0 (guard off) → 7 of the new assertions go red; the ones asserting non-blocking behavior correctly stay green.
  • Fingerprint mutated to ignore the body, guard still on → 3 tests go red, including the "a different message is not blocked" negative control.

Codex's own 13 executable probes go from 1 passing to 12. The one that remains is the provider-truncation equivalence it classified itself as a scope/wording issue — Graph truncates a subject at 255 chars, so two different inputs can leave as identical mail; addressed by correcting the README to describe the fingerprint as effective action inputs rather than final wire bytes.

OpenSpec change: openspec/changes/add-duplicate-send-guard (validates --strict, 13 scenarios each backed by a matching test name).

https://claude.ai/code/session_01HkdNf9x4QS4LYS2DSiQWXS

`isRetryable(err, 'delivery') === false` and the single-attempt dispatch in
send_email/reply_to_email/send_draft guarantee that this library never
re-issues a send. They guarantee nothing about the caller, and the caller is
the one that replays.

The residual hole: an agent whose tool result was lost — a compacted context, a
dropped transport, a supervisor restarting the turn, a person retrying a call
that looked stuck — re-issues the identical instruction, and the second call is
indistinguishable from a first one. The only defence was a sentence of English
in each tool description ("do not resend without checking Sent Items"), which
is exactly the guard an LLM under retry pressure skips. When it fails it fails
silently: the second send succeeds and the recipient gets the message twice.

Provider endpoints take no idempotency key, so this cannot be fixed at the
provider boundary. It is fixed one level up, with the state nothing previously
held: an in-process ledger keyed by a digest of what would actually be
delivered — mailbox, recipients, subject, rendered post-truncation body,
attachment content hashes, scheduled time, and the reply parent or draft id.
The record is written BEFORE dispatch, so the reserved window is exactly the
window in which an acknowledgement gets lost, and settled after.

Three refusal codes rather than one, because the recovery differs:
DUPLICATE_SEND_IN_FLIGHT (wait), DUPLICATE_SEND_BLOCKED (stop — the prior
messageId comes back with it), DUPLICATE_SEND_UNRESOLVED (a human checks Sent
Items).

Release is an allowlist, not a denylist. Only codes that PROVE non-delivery —
4xx-derived, 429, connect-failure — free the fingerprint, so a resend after a
rejection is never blocked. Everything else, including a code no provider has
shown us yet, is held. A wrongly-held message costs one refused resend and
prints the override; a wrongly-released one costs a duplicate in somebody's
inbox, which is the bug being fixed.

On by default, with no embedder wiring. ActionContext.sendLedger exists only so
tests can inject an isolated ledger; when absent the actions fall back to the
process default. ActionContext.rateLimiter is the cautionary example — an
optional injected interface that no shipped adapter constructs, so the rate
limit it documents does not exist in the product. A guard someone has to
remember to wire is a guard that is off.

send_draft claims before the draft lookup, not after: a provider that consumed
the draft on the first send makes the second lookup fail, and "cannot verify
draft recipients" is a misleading answer to "did this already go out?". Every
bail-out between claim and dispatch releases.

allow_duplicate: true is the deliberate escape hatch, and re-arms the guard
against its own replay.

Limitation, stated rather than papered over: the ledger is per-process and in
memory. It closes the replay window that actually occurs — a retry inside a
running server — and does not survive restarting that server. Cross-process
durability needs a persisted store and is deliberately not attempted here.

Evidence: 559 email-core tests pass (up from 546). Negative control run —
rebuilding the suite with the ledger window forced to 0 turns 6 of the new
assertions red and leaves the three that assert non-blocking behavior green, so
the tests can fail. A separate check drives the built dist through an
ActionContext with no sendLedger and confirms the unwired production path is
guarded; it is kept as the "Guard is active without embedder wiring" test
rather than as a one-off script.

Ref: openspec/changes/add-duplicate-send-guard

Claude-Session: https://claude.ai/code/session_01HkdNf9x4QS4LYS2DSiQWXS
Codex peer review found five executable defects in the duplicate-send guard.
Each was reproduced against the tree before being fixed, and each now has a
regression test that fails without the fix.

1. Settle and release addressed only the fingerprint, so they acted on whatever
   attempt occupied the key. A fingerprint can legitimately carry two live
   attempts, because allow_duplicate creates one. Interleaving: A dispatches; a
   forced B dispatches and succeeds; A comes back rejected and DELETES B's
   successful record; an ordinary replay then sends a third copy. Attempts now
   carry an identity and the claim closure holds its own entry.

2. A forced claim discarded the prior record before the forced send was known
   to have delivered, so a rejected override erased the delivery it was
   overriding and the next ordinary call sent again. No concurrency needed. The
   forced attempt is now recorded alongside the earlier one. Same defect at the
   action layer in send_draft: forcing a consumed draft failed its lookup,
   released, and left an ordinary replay reporting DRAFT_LOOKUP_FAILED instead
   of naming the message that had already gone out.

3. The process-default ledger was keyed on ctx.mailboxName, which is optional.
   Two mailboxes in one process shared the empty key: the second mailbox's
   FIRST EVER send was refused and handed the first mailbox's message id — a
   wrong refusal and a cross-mailbox identifier leak in one response. The
   namespace now falls back to a per-provider-instance id held in a WeakMap.
   The shipped MCP wrapper always supplies a resolved name, so this was the
   library and default-context surface, not that server.

4. A success that settled after its reservation had been pruned was dropped on
   the floor, admitting an immediate replay of a message that had just been
   delivered. A late settlement is re-inserted and dated from settlement.

5. Size-cap eviction could evict an in-flight reservation — dropping cover for
   a send that is on the wire, the worst thing this module can do. Eviction now
   takes the oldest SETTLED attempt and never an active one. A burst of
   concurrent sends may briefly carry the map past the cap; refusing a claim
   because the ledger is full would block first-ever sends, which is worse.

Also from the review, verified at each production site:

- DELIVERY_PROVEN_UNSENT_CODES was incomplete for the shipped providers. Graph
  REPLY_FAILED (prepareReplyDraft throws before /send),
  ATTACHMENT_TOO_LARGE_FOR_PROVIDER (size validation, zero POSTs),
  ATTACHMENT_UPLOAD_FAILED (draft created, /send never called) and
  SCHEDULE_DRAFT_FAILED (no draft id, no deferred-send time) all provably
  deliver nothing, yet each held a fingerprint and produced a false refusal.
- Gmail reported a delivery whose connection never opened as generic
  PROVIDER_ERROR, which cannot be released because the same code also covers
  failures that are not proven. It now reports PROVIDER_UNREACHABLE for that
  transport condition, matching what the Graph provider already does. Generic
  PROVIDER_ERROR is deliberately NOT added to the allowlist.

Documentation corrections, including one claim that was false when written:

- The proposal said the ledger stores a "salted digest". No salt was ever
  implemented. Corrected in place with a note saying what it previously
  claimed, rather than quietly deleted.
- The spec said a colliding claim SHALL NOT "invoke the provider". Too broad:
  reply_to_email reads the parent message before consulting the ledger, and
  send_draft reads the draft. It is the delivery dispatch that is suppressed.
- The override was described as replacing the prior record. It is recorded
  alongside it, and allow_duplicate is an unconditional per-request bypass — a
  replay that itself carries the flag dispatches again. Nothing here makes the
  forced request idempotent.
- The README described the fingerprint as the bytes that leave the process.
  Providers transform outbound mail (Graph truncates a subject at 255), so it
  is the effective action inputs. The guard catches a replayed tool call.

Evidence: 569 email-core tests and 119 provider-gmail tests pass, up from 559
and 116. Codex's own 13 executable probes go from 1 passing to 11; the two that
remain are the ones it classified as out of scope for this commit — the
pre-existing scheduled-send fallback code, fixed separately next, and the
provider-truncation equivalence, which is addressed as wording above.

Ref: openspec/changes/add-duplicate-send-guard
Ref: .peer-review/dup-send-guard-1788823130-24309-codex.md

Claude-Session: https://claude.ai/code/session_01HkdNf9x4QS4LYS2DSiQWXS
Scheduling is a two-write draft→send. When the provider itself classifies a
failure the action passes that code through, but the catch-all fallback for an
unclassified throw was SCHEDULE_SEND_FAILED — a terminal code asserting the
submission was rejected, for an outcome that proves nothing of the kind.

handleProviderError already states the rule in its own doc comment: for a
delivery operation the fallback MUST be an ambiguous code, because an
unclassified failure on a send is exactly the case where we cannot prove the
message did not go out. The immediate-send path follows it; the scheduled path
did not. A caller told "failed" for a submission that may have been queued is
being invited to schedule a duplicate.

The fallback becomes SCHEDULE_SEND_STATUS_UNKNOWN, and quota is charged on it
for the same reason the non-throw branch two lines above already charges it.
SCHEDULE_SEND_FAILED is unchanged where the Microsoft provider classifies a 4xx
rejection — that code is still correct and still proves non-delivery.

Found during Codex peer review of the duplicate-send guard and confirmed to
predate it, so it lands as its own commit rather than inside that change. The
guard was already correct here regardless: an unclassified throw settles with
no code and is held as unresolved, so a replay was refused either way. This
fixes what the CALLER is told.

Tests: the ambiguous fallback, plus a negative control asserting a
provider-classified SCHEDULE_SEND_FAILED still surfaces as terminal — without
it the fix could have blurred the distinction it exists to preserve.

Claude-Session: https://claude.ai/code/session_01HkdNf9x4QS4LYS2DSiQWXS
@stevenobiajulu
stevenobiajulu enabled auto-merge (squash) September 8, 2026 00:30
@stevenobiajulu
stevenobiajulu merged commit e4e16ca into main Sep 8, 2026
13 checks passed
@codecov

codecov Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.89119% with 6 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
packages/email-core/src/security/send-ledger.ts 96.37% 4 Missing and 1 partial ⚠️
packages/email-core/src/actions/draft.ts 92.85% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@stevenobiajulu
stevenobiajulu deleted the tweak-duplicate-send-guard-20260907 branch September 8, 2026 00:35
@stevenobiajulu

Copy link
Copy Markdown
Member Author

✅ Post-merge smoke passed

Merged: e4e16ca3f47706e1f594b697b3b7631b3f86eb9e (squash)
Built from: main @ e4e16ca
Smoke: clean rebuild from merged main (dist/ deleted, npm ci) + full suite + real MCP stdio session + feature end-to-end

Steps

  • ✅ clean build (no artifacts carried over from the feature branch)
  • ✅ lint (all workspaces)
  • ✅ tests — 571 core, 236 mcp, 119 gmail, 219 microsoft, 31 broker
  • ✅ spec coverage — 290/290
  • ✅ isolated runtime — both published entry points boot and answer with 26 tools
  • ✅ feature end-to-end — 19/19

Feature end-to-end

Two halves, both against the artifact this merge produced rather than the source tree.

Shipped MCP surface — spawned packages/email-agent-mcp/bin/email-agent-mcp.js, did a real initialize + tools/list over stdio, and asserted on the returned schemas. This is what confirms the zod input actually reaches a client:

  • send_email, reply_to_email, send_draft each expose allow_duplicate
  • each description carries the DUPLICATE_SEND_* warning

Guard behaviour — the built dist actions driven through a production-shaped ActionContext with no ledger injected, which is the shape the MCP adapter builds:

  • an identical replay is refused DUPLICATE_SEND_BLOCKED, carries the original messageId, and the provider is dispatched exactly once
  • a message differing only in body still delivers (negative control — a guard that blocks everything is not a guard)
  • two mailboxes with no mailboxName sharing the process-default ledger each dispatch once, with no cross-mailbox messageId leak
  • a resend after a proven rejection (INVALID_REQUEST) is allowed
  • a rejected allow_duplicate still leaves the original delivery blocking an ordinary replay — the P1 regression from review

Synthetic identities only (jane@example.com, "Q3 board packet").

Visual evidence: n/a — no user-visible rendered output.
Prod: n/a — repo does not deploy on merge.
Log: /tmp/automerge-smoke-209-20260907-203345.log (local).

Advisory-gate audit: no bot/gate comments and zero legacy commit-statuses on the head SHA, so nothing green-but-degraded is hiding behind a passing check. All four required contexts (workspace-lint, spec-coverage, workspace-test (20), workspace-test (22)) genuinely passed, alongside codeql, semgrep, actionlint, commitlint, server-json-contract and gemini-manifest.

Cleanup

  • ✅ deleted remote branch tweak-duplicate-send-guard-20260907
  • ✅ removed worktree .worktrees/dup-send-guard (peer-review artifacts preserved to the primary checkout's gitignored .peer-review/ first)
  • ✅ deleted local branch, pruned origin

stevenobiajulu added a commit that referenced this pull request Oct 7, 2026
Releases #209 (caller-replayed duplicate delivery guard), #211 (save_to for
download_attachment and the new download_message tool) and #212 (CodeQL
ReDoS fix), which landed on main after the 0.3.2 tag. Agents run the
installed server, not main, so these stay unavailable until this ships.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant