Skip to content

feat(messaging): mark a code send that leaves the sender to the project - #127182

Closed
Silthus wants to merge 12 commits into
email-v2/08-admission-checks-djangofrom
email-v2/09-route-idempotency
Closed

Silthus wants to merge 12 commits into
email-v2/08-admission-checks-djangofrom
email-v2/09-route-idempotency

Conversation

@Silthus

@Silthus Silthus commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Moved from Silthus#578.

Re-cut on 2026-11-02 onto #127167, after the scope cut parked local templates and sends by key. This layer keeps only what still applies to a code send.

Part of the code-first rebuild of the email send stack; replaces parts of #121867 (one send per idempotency key on send/email/).

Problem

  • A developer who retries a keyed code send after the project's default sender changed gets 422 idempotency_key_reused, and the first send's answer is lost.
  • The caller sent the same request. Only the sender PostHog picked for them changed.
  • This is the Django half. feat(cdp): key a code send on the sender the caller chose #127129 is the cdp-api half, which keys a code send on the sender the caller chose.

Impact

Pays into Grow Workflows through billed workflows_emails actions per paying org: a retry with the same key replays instead of failing. In scope: Messaging, email API sends.

Merge Danger

Note

🟢 Merge risk: low · two-way door: a revert keys a default-sender send on the resolved sender again

  • Code sends on send/email/ only, behind workflows-email-from-code. Template sends keep the template's sender.
  • No merge order with feat(cdp): key a code send on the sender the caller chose #127129. An older cdp-api ignores the marker and keys on the resolved sender, as today. A newer cdp-api without the marker does the same.
  • A keyed code send queued before this layer deploys and retried after it gets 422 once. Code sends are not on master, and the flag is on only for the PostHog organization, so no customer has such a send queued.

Changes

Kind Files Lines
Production 1 +6 −1
Tests 1 +7 −2
  • A keyed code send without from replays after the project's default sender changed. Django marks the sender it resolved as the project default (send.from.isDefault), and cdp-api leaves it out of the key.
  • A send that names a from keys on that address, as before.
  • The idempotency store, its replayed answer and the 422 and 409 refusals are master's, unchanged.

How did you test this code?

Test seams: send/email/ through the DRF client, asserting the body posted to cdp-api (test_transactional_email.py).

Test rationale: the code-send rows that compare the whole send dict now expect isDefault: true without from, and no marker with one. A marker that is missing, or set on a named sender, fails them.

Proofs at 1aad7ab (saved with fleet proof):

Earlier runs at the pre-cut stack
  • Live run, 2026-10-31. Django at the top of the stack (3a30f51c529), cdp-api and both CDP workers at feat(cdp): send a template from the workflow version Django graded #127130 (f9abed0841c), mail caught in maildev.

    Step Result
    Keyed code send without from 202, sent from the default sender@example.com
    Switch the project's default sender to other@example.com, then resend the same key and body 202 replayed: true with the same id, one email in total
    New code send without from 202, sent from other@example.com
    Same key and body twice before the switch 202, then replayed: true, one email

Release status

  • This change is behind a feature flag and is not available to users

Docs update

None.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code, Claude Opus 5.5 (claude-opus-5-5)

Question Answer Who Where
Does this PR keep its own idempotency store? No; it reuses master's store and refusal code, and keeps the code-aware PIPELINE_REFUSALS decoder 👤 Human map Silthus#461, 2026-10-28
Does a default-sender change break a keyed retry? No: Django marks the default sender and cdp-api leaves it out of the key 👤 Human map Silthus#461, lane brief of 2026-10-25
May the code carry comments? No; names carry the reasons 👤 Human map Silthus#461, 2026-10-25

Involvement: one brief with Michael's shrink decision; one review round (GPT-6.1 Sol) with no findings; QA swarm on 2026-11-01 with one panel round (GPT-6.1 Sol review, Opus 5.5 fresh eyes): 1 finding fixed by running the owed proofs; later changes are merges from below; live, shadow-diff and cross-version proof captured. After the re-cut, one panel round (GPT-6.1 Sol lenses, Opus 5.5 fresh eyes): 1 finding answered with proofs at this head, 2 rejected with reasons.

🤖 Generated with Claude Code

Silthus and others added 9 commits October 31, 2026 15:54
A code send without `from` now tells cdp-api its sender is the project's
default (`from.isDefault`). cdp-api keys an Idempotency-Key retry on the
sender the caller chose, so a retry after the default sender changed
replays the first send instead of answering that the key was reused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oute-idempotency

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oute-idempotency

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oute-idempotency

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oute-idempotency

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

🤖 CI report

⚠️ Trunk lane — backend Python lane

This PR is assigned to the backend Python lane. It runs backend Python tests and may merge in parallel with PRs in other lanes.

✅ Duplication (Python) — clean

New Python code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

✅ Duplication (TypeScript) — clean

New TypeScript code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

✅ Django migration risk — migration analysis complete

We've analyzed your migrations for potential risks.

Summary: 4 Safe | 0 Needs Review | 0 Blocked

✅ Safe

Brief or no lock, backwards compatible

messaging.0012_messagetemplate_key
  └─ #1 ✅ AddField
     Adding nullable field requires brief lock
     model: messagetemplate, field: key
messaging.0013_messagetemplate_unique_key_per_team
  └─ #1 ✅ SeparateDatabaseAndState
     Wrapper operation - see nested operations for risk: CreateIndexConcurrently
     database_operations: CreateIndexConcurrently
     └─ #2 ✅ CreateIndexConcurrently
        PostHog concurrent-index helper: idempotent (timeout disabling + invalid-leftover recovery)
        model: None, index: unique_message_template_key_per_team
messaging.0014_transactional_email_key
  └─ #1 ✅ CreateModel
     Creating new table is safe
     model: TransactionalEmailKey
  │
  └──> ℹ️  INFO:
       ℹ️  Skipped operations on newly created tables (empty tables
       don't cause lock contention).
messaging.0015_messagetemplate_code_managed_fields
  └─ #1 ✅ AddField
     Adding nullable field requires brief lock
     model: messagetemplate, field: content_hash
  └─ #2 ✅ AddField
     Adding nullable field requires brief lock
     model: messagetemplate, field: managed_by
  └─ #3 ✅ AddField
     Adding nullable field requires brief lock
     model: messagetemplate, field: source_path
  └─ #4 ✅ AddField
     Adding nullable field requires brief lock
     model: messagetemplate, field: source_ref
  └─ #5 ✅ AddField
     Adding nullable field requires brief lock
     model: messagetemplate, field: source_repository
  └─ #6 ✅ AddField
     Adding nullable field requires brief lock
     model: messagetemplate, field: superseded_content_hashes

📚 How to Deploy These Changes Safely

AddField:

This operation acquires a brief lock but doesn't rewrite the table.

Deployment uses lock timeouts with automatic retries, so lock contention will cause retries rather than connection pile-up.

Last updated: 2026-11-02 10:34 UTC (3ffb935)

Silthus and others added 2 commits November 2, 2026 15:53
A code send without `from` now tells cdp-api its sender is the project's
default (`from.isDefault`). cdp-api keys an Idempotency-Key retry on the
sender the caller chose, so a retry after the default sender changed
replays the first send instead of answering that the key was reused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…route-idempotency

The scope cut parks local templates. This merge takes the tree of this
layer's own commits replayed onto #127167, so the branch
drops the parked layers that sat below it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus removed this pull request from stack #127192 November 2, 2026 20:06
@Silthus
Silthus added this pull request to stack #127772 November 2, 2026 20:08
@Silthus
Silthus removed this pull request from stack #127772 November 2, 2026 20:08
@Silthus
Silthus added this pull request to stack #127774 November 2, 2026 20:08
@Silthus
Silthus removed this pull request from stack #127774 November 3, 2026 08:22
@Silthus

Silthus commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Closing. Marking a send that uses the default sender changed its idempotency key, so a send accepted before the marker existed and retried after this deploy, or the other way across a rollback, would get 422 instead of a replay. #127129 now keys every code send on the sender Django resolves, so retries replay across deploys. The cost: a retry after the project's default sender changed between a send and its retry gets 422.

@Silthus Silthus closed this Nov 3, 2026
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