Skip to content

feat(workflows): mirror the sandbox ses tenant state in django - #607

Draft
Silthus wants to merge 1 commit into
workflows-sandbox-product/sender-confirmationsfrom
workflows-sandbox-delivery/tenant-pause-mirror
Draft

Silthus wants to merge 1 commit into
workflows-sandbox-product/sender-confirmationsfrom
workflows-sandbox-delivery/tenant-pause-mirror

Conversation

@Silthus

@Silthus Silthus commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

Problem

All sandbox sends go out through one shared PostHog SES tenant.
When AWS pauses that tenant, the email worker needs to know, so it can stop sending through it.
This PR makes Django record the shared tenant's pause state, so the worker gate in the next PR can read it.

Part of Silthus/personal#199 (sandbox delivery). Stacked on #616.
Replaces the Django half of #605, which mixed a migration with nodejs/ changes. Originally #452 and #453.

Stack

Impact

Grow Workflows, activation rate: the pause gate is a condition for enabling the sandbox sender in production.
No production team sees this yet: WORKFLOWS_SANDBOX_SENDER_ENABLED stays off, and SES_SANDBOX_TENANT_NAME is empty.

Merge Danger

Warning

🟡 Merge risk: medium · one-way door (merge-risk rubric)

  • New table workflows_sandboxsendertenantstate (migration 0039). It is a plain CREATE TABLE with no foreign keys, so the migration takes no lock on an existing table.
  • The SES tenant-state webhook and the daily reconciliation now also sync the tenant named by SES_SANDBOX_TENANT_NAME. With the setting empty, they do nothing new.
  • The team tenant sync moved its reputation lookup into get_tenant_sending_state. Team tenant tests pin the same results.
  • Safe to deploy alone: nothing reads the new table until the worker gate (feat(workflows): skip sandbox sends while the shared ses tenant is paused #608) deploys. The reload-sandbox-sender-state message reaches no subscriber before then, so Redis drops it.

Production lines: 191 changed (git diff --numstat against #616; tests, docs and the migration excluded).

Changes

  • Django mirrors the shared sandbox tenant into SandboxSenderTenantState:
    • from the SES tenant-state webhook, through a Celery task;
    • from the daily reconciliation, beside the team tenants. A failed sandbox sync does not stop the team sweep.
  • A committed state change publishes reload-sandbox-sender-state for the workers, through transaction.on_commit(..., robust=True).
  • The sandbox tenant never emails project admins. It belongs to no team.
  • SES_SANDBOX_TENANT_NAME is a new Django setting.

How did you test this code?

Live run on the slice tip

  • Live run of the V3 tip 7e95863f7a0 on a PostHog preview. Stubs: a local SES endpoint that records each call, and example.com seeded as a valid MX domain; a local SNS signing certificate, so a signed delivery webhook verifies; the trigger event produced straight onto the CDP events topic; personhog pointed at the persons database.

  • SES tenant DISABLED, then the Django sync: the mirror row reads DISABLED, and the next sandbox send is skipped with "the sandbox sender is paused right now". ENABLED and a sync: the next send goes out. The worker picked up each change without a restart.

  • A live workflow send on the sandbox sender recorded email_sent and email_sandbox_sent once each, the pair the sending tier subtracts.

  • A signed SES Delivery notification for that message on the sandbox configuration set returned 200 and captured workflows sandbox email delivered. The same envelope with a forged signature returned 403.

  • Lane gate on HEAD: the pre-push hogli ci:preflight --strict passes (ruff, format, type-check, migrations, semgrep-devex). The lane classifier puts this PR on the Python side only, and it has no nodejs/ file.

  • Red: on feat(workflows): cap sandbox sends per project and recipient each day #604's code, test_ses_tenant_state.py cannot load (ImportError: cannot import name 'sync_sandbox_tenant_state_task').

  • Green on HEAD: test_ses_tenant_state.py and test_ses_tenant_events.py, 48 passed.

  • Test seams: sync_sandbox_tenant_state, the tenant-state webhook, and reconcile_ses_tenant_states, with the SES client mocked.

  • Proofs owed (fleet proof gate):

    • Review: GPT-6.1 Sol, 1 round, 1 finding rejected with a reason (a concurrency test for the row lock; see the ticket).
    • Live run: passed at the stack tip (69afe3e4b1f), recorded on every delivery head. One real email worker process (cdp-cyclotron-worker-email) on scratch Postgres, with real Redis, Kafka and ClickHouse: a member received the email, a non-member was skipped, a paused tenant was skipped, the daily team cap held, and each delivered send recorded email_sandbox_sent, which the tier's own ClickHouse read then left out. Stubbed: the SES endpoint (a local HTTP stub behind the real SESv2 client), the MX verdict for the reserved example.com domain, and the tenant-state rows the Django sync would write.
    • Merge-queue lanes: CI computes this check against master, so it reads as mixed until the Node layers below land. Land the stack bottom-first, not with one trunk merge on the top layer.
    • Prod shape: gap, accepted until read-only production access arrives. The sandbox tenant is not configured in production.
    • CI: gap. Fork CI cannot run on Silthus/posthog.

Test rationale: the Django tests cover the webhook and sweep paths that write the mirror, including a failed sandbox sync that must not stop the team sweep.

Release status

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

Docs update

docs/internal/workflows-email-sending-tiers.md documents the Django mirror and SES_SANDBOX_TENANT_NAME.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code, Claude Opus 5.5 (implementation). Review by Codex, GPT-6.1 Sol.

Split out of #605 and #606 so each PR passes upstream's merge rules: a migration does not share a PR with nodejs/, and Python does not share a PR with Node. The code is carved by file from those reviewed heads without changes; the stack's tip has the same tree as #606.
Skills: tdd, /writing-tests, pr-description, merge-risk.
Public artifact: all test data is invented.

Decisions

Question Answer Who Where
Slice order of the sandbox sender stack Option B: the Channels row moves into V1 so a team has a sender to test from; V1's worker PRs land first 👍 Accepted default map
How to split #605 and #606 Four fresh single-commit branches off #604, in deploy order: Django mirror, worker gate, Python tier, worker metric; #605 and #606 close 🤖 Agent ticket
How a mixed-lane stack lands Bottom-first; CI checks lanes against master, so each layer passes once the layers below it land 🤖 Agent ticket
Where the docs lines go Each half documents its own behavior; the tip's doc matches #606 🤖 Agent ticket
Where SES_SANDBOX_TENANT_NAME lives This PR adds the one setting line the Django mirror needs; whichever of this PR and the sandbox product PR lands second keeps the product PR's settings block 🤖 Agent ticket
Migration number 0039, after master's 0038 🤖 Agent ticket
Involvement: no grilling rounds; design pass carried from #605 and #606 (the code is unchanged); review (GPT-6.1 Sol, 1 round, 1 finding rejected); proof captured as test output and a live run.

🤖 Generated with Claude Code

Django records the pause state of the shared sandbox SES tenant in
SandboxSenderTenantState, from the tenant-state webhook and the daily
reconciliation, and announces each committed change to the workers.
Nothing reads the table yet; the worker gate follows in its own PR.

Refs Silthus/personal#199

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus force-pushed the workflows-sandbox-delivery/daily-caps branch from d68a209 to e189abd Compare October 28, 2026 10:00
@Silthus
Silthus force-pushed the workflows-sandbox-delivery/tenant-pause-mirror branch from e8da316 to c8ee93b Compare October 28, 2026 10:00
@Silthus
Silthus changed the base branch from workflows-sandbox-delivery/daily-caps to workflows-sandbox-product/sender-confirmations October 28, 2026 10:01

This branch has not been deployed

No deployments
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