Skip to content

feat(cdp): refuse a sandbox code send before it is queued - #637

Draft
Silthus wants to merge 2 commits into
sbx-api/sa1a-envelopefrom
sbx-api/sa1b-gate
Draft

Silthus wants to merge 2 commits into
sbx-api/sa1a-envelopefrom
sbx-api/sa1b-gate

Conversation

@Silthus

@Silthus Silthus commented Nov 1, 2026 •

Copy link
Copy Markdown
Owner

Part of map #461 (sandbox sender for API sends). SA1 part 2 of 3, split from #632: the admission gate. Stacked on #636 (part 1). Part 3 is #638, and #633 sits on top.

Problem

  • A project without a verified domain gets 202 for a code send through the sandbox sender, even when the sandbox skips it after the queue: a non-member recipient, a spent daily allowance, or a paused sender.
  • The caller learns about the skip only from the run log, so an SDK can't tell a sent email from a dropped one.

Merge Danger

Warning

🔴 Merge risk: high · two-way door: a revert brings back the 202 for every sandbox code send

  • Every CDP integration lookup now reads an id the read replica lacks from the primary.
  • The new checks run only for a sandbox integration, which exists only behind workflows-sandbox-sender.

Changes

  • A sandbox code send that the sandbox would skip is refused before it is queued. Nothing is queued on a refusal.
Case Answer
A To, CC or BCC recipient is not a verified member, or a stored copy can't be read 400 sandbox_recipient_not_allowed
The project or recipient allowance is spent 400 sandbox_limit_reached
The sandbox is switched off or paused, or the organization is blocked 422 sending_suspended
The pause, member or allowance check can't run 503 sandbox_check_failed
  • The allowance check only reads the buckets, so a burst past what is left still gets 202, and the worker skips the extra sends.
  • A refusal emits workflows sandbox email blocked with source: api, and waits at most a second for it.
  • The CDP API and the worker find an integration Django just created, before the read replica has it.

How did you test this code?

Test seams: the cdp-api code-send route over HTTP (cdp-api.serial.test.ts) and IntegrationManagerService.get.

Test rationale: each refusal row above, a refusal while the blocked-event store hangs, and a lookup the replica misses going to the primary.

Proofs: the live run, cross-version run and shadow diff ran on #632, whose code equals this stack's top (part 3). Prod shape is a gap until read-only production access exists on 2026-11-02.

CI: this PR sits on an integration base until the sandbox stack (through #587) and the send stacks (through #584 and #585) reach master.

Release status

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

Docs update

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

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

Skills: /tdd, /writing-tests, /qa-swarm, /pr-description, /writing-pr-descriptions

Question Answer Who Where
Which codes do the recipient and allowance refusals use? 400 sandbox_recipient_not_allowed and 400 sandbox_limit_reached 👤 Human map #461, 2026-10-26
Are the allowances refilling buckets? Yes, accepting about twice the cap on the first day and one recipient bucket per organization 👤 Human map #461, 2026-10-26
How is SA1 split for review? Three stacked PRs: envelope and source, the admission gate, delivery_mode 👍 Accepted default map #461 lane handoff, 2026-11-01
What answers a suspended sandbox? 422 sending_suspended, the master code for a suspension 🤖 Agent #632
Where does a sender the replica lacks get found? In the integration loader, for admission and the worker, not per caller 🤖 Agent #632

Involvement: the SA1 review carries over from #632, whose code these three parts hold: one design pass, a 7-lens security swarm, a 4-lens fix round and one clean final read (GPT-6.1 Sol and Claude Opus 5.5). One GPT-6.1 Sol read of this part raised one minor, rejected as in #632 (Django answers a lookup failure with a 503). A GPT-6.1 Sol read of the merge that took the newer base came back clean.

More context for AI agents

🤖 Generated with Claude Code

…t is queued

A code send that goes through the sandbox sender now gets the worker's sandbox checks before it is queued: the switch, the pause, the organization block, the member check on every to, cc and bcc address, and a read-only look at the daily allowance. A refused send answers `400 sandbox_recipient_not_allowed`, `400 sandbox_limit_reached`, `422 sending_suspended` or `503 sandbox_check_failed`, and nothing is queued. The refusal's blocked event waits at most a second.

The integration loader reads an id the read replica lacks from the primary, so a sandbox integration Django just created is found by the admission check and the worker.

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

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