feat(sending): compose ramp with protection gate - #992
Merged
Conversation
B4 of the sending-protection plan. The custom-domain ramp and the sending budget answer different questions — "has this domain earned this volume yet?" versus "has this account or the platform exposed SES enough today?" — and this slice makes one transaction answer both, most restrictive winning. One lock order for the ramp. The store previously used three: Reserve took domain → scope → reservation → counter, Confirm took reservation → counter → scope, Release took reservation → counter. Three orders over four keys is a deadlock waiting for traffic, and it becomes unavoidable once the gate composes both ledgers into one transaction, because that transaction already holds highly contended global budget counters when it reaches the ramp. internal/sendramp/tx.go now holds the single order (domain identity → registrable-domain scope → message reservation → UTC day counter) and the pool-owning methods are thin wrappers, so there is no second implementation to drift. Composition, in internal/sendingpolicy/ramp.go: - Probation is now the ramp's answer rather than a stand-in. Shared-relay traffic stays probationary at every plan level and never graduates; a custom domain is probationary until its scope has one qualified day. That classification decides which budget counters the transaction must lock, and the budget counters come BEFORE the ramp keys in the normative order — so it is read unlocked. That is sound only because ramp progress is monotonic: a stale answer can be stale in the strict direction and no other. - The ramp is authorized last, after the budget has been reacquired. A ramp hold therefore arrives with budget units already taken, and those are released before returning the hold — keeping them would charge an account for a send its own domain was not allowed to make. - SettleProvider now moves the ramp: acceptance advances a qualified day, a definite permanent rejection releases the units, and retryable or ambiguous results leave the reservation standing, because a message that might have been delivered must not release capacity. - CancelAttempt releases both ledgers; DeferAttempt still releases only the budget. A rate deferral was not rejected by anyone, and giving back its ramp claim would let the same message re-qualify a stage it already qualified. Disabled mode is genuinely pass-through: no scope row, no counter, and above all no `exempt` write. Writing `exempt` while the ramp is off would permanently grandfather every domain that happened to send during the disabled window, and the phase-3 activation would then find nothing left to ramp. Production ships in exactly this state. A fixture note worth keeping: the ramp ledger is keyed by REGISTRABLE domain, so `ramp-1.example.test` and `ramp-2.example.test` are one scope. The first version of these tests read the hostname key, found empty rows, and would have passed for the wrong reason. Each fixture domain is now its own eTLD+1, and the one test that is about sharing builds two hostnames under a single registrable domain deliberately. Tests: 264 in the package plus the existing ramp suite, green under -race. Covers the 150/213/277 stage caps and their 75/107/139 qualification bars, every probation class, budget-allow/ramp-hold and its mirror, settlement idempotency, permanent-rejection release, defer-versus-cancel, subdomain scope sharing, and a Free account that can qualify stage one but not stage two without losing its progress. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
jiashuoz
force-pushed
the
feat/sending-protection-ramp
branch
from
September 4, 2026 01:19
2dd6649 to
26c96b9
Compare
Review of the ramp composition found seven ways the stage cap stopped being a cap. All of them come from one shape: the sending budget is keyed by submission ATTEMPT while the ramp reservation is keyed by MESSAGE, and code that treats the two as interchangeable gives away capacity that was already spent. The refund. CancelAttempt released the ramp for any cancelled attempt. Attempt one authorizes and hands 100 recipients to SES, the result is ambiguous so nothing settles and the reservation correctly stands, River allocates attempt two, a suppression cancels it — and the ramp counter goes to zero for mail that is already in flight. It repeats, so the cap became advisory. cancelRamp now asks about the OPERATION rather than the ordinal: if any attempt was ever authorized, only SettleProvider may give those units back. A reservation no attempt has authorized — the shape today's outbound worker produces before this module is involved — is still refundable, which is also where the mirror bug lived: the `released` early return meant a cancel following a deferral never reached the ramp at all. The stranding. A permanent sendramp error (`reservation already released` is reachable by Reserve-after-Cancel on one operation) came back as a hard error, rolling the transaction back with the attempt still `reserved`. Every later execution failed identically, so nothing could ever release its units: 50 recipients pinned on global_all, global_probation, and account_daily until midnight, three of them enough to close probationary sending for the platform. Permanent refusals are now a terminal hold that releases, exactly as the envelope path already answers the same class of loss. The rebind. A domain with an unverified SENDING identity was ramp pass-through AND, because InspectScopeTx never read the column, reported established once its scope had a qualified day — no cap and no probation charge. The wire identity is frozen at acceptance but the agent's registered domain is not: verifying a child subdomain rebinds the account's agents onto it, the child's SES identity stays pending while its DKIM is never published, and the ramp resolves the domain live. An accepted backlog went out uncapped under the parent's frozen From. It now holds, and classifies as probationary. The two legacy states that mean "this domain already earned its volume" — `exempt` and `complete` — are checked first and are untouched. Three smaller ones. Reserve still classified probation as `op.Shared` with a comment deferring to a task this commit is; the early hold therefore never bounded the probation pool, the stored column disagreed with the class every release targets, and each authorization paid a needless release-and-reacquire on the platform's hottest counter rows. The ramp's own source read answered a vanished message with a RETRYABLE hold, and because it runs before the envelope resolution that answers it terminally, the wrong answer won whenever the ramp was armed — a worker snoozing forever instead of failing once. And in the ledger itself: a released-to-confirmed restoration errored instead of no-opping when maintenance had reaped the day's counter (the reservation outlives it by design), while ReleaseTx recorded `released` even when its guarded decrement matched nothing, so a later restoration added back units the counter never returned. Tests: every fix above has a regression test that fails before it. The file also gained the coverage Task 4 Step 2 asks for and did not have — racing workers against one stage cap with the budget counters contended (exactly one cap admitted, no deadlock), cross-midnight re-age of BOTH ledgers in one authorization, settlement arriving days after its attempt, and a FOR UPDATE NOWAIT probe that proves the named domain → scope → reservation → day-counter suborder is actually taken rather than merely documented. Weak assertions were tightened: the probation tests now assert the hold REASON, the ramp-hold test checks all three pools it charged, and the Free-plan test proves progression RESUMES after upgrade rather than merely surviving it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
InspectScopeTx decides which budget pool a send charges before the ramp lock is taken, but every test of it lived in sendingpolicy, so the per-package coverage gate saw it at 0% and failed the package at 76.6%. Pin each branch where the code lives: missing domain, legacy exempt, stamped complete, unverified identity with an old scope (must stay in probation and ignore the scope), verified with no scope (day zero), a completed scope behind a still-ramping domain row, the day-zero/day-one boundary, and registrable-domain scope lookup with a hostname-keyed decoy. Removing the unverified guard makes the fourth test fail. Package coverage: 76.6% -> 84.9%. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
Settlement reports what the provider did, so it is only meaningful for an attempt that opened the socket. The adapter (B5, #998) redeems the token immediately before it dials, and the gate there refuses to settle an attempt whose call_state is not 'started'. These tests settled straight after ConsumeAttempt; route them through a consumeAndRedeem helper so the two slices can land in either order. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
B4 of the sending-protection plan (Task 4). Stacked on #990 — base is
feat/sending-protection-gate, notmain, because this slice needs B3'sGate. Review #990 first; this PR's diff is only the ramp composition.The ramp and the budget answer different questions — has this domain earned this volume yet vs. has this account or the platform exposed SES enough today — and this makes one transaction answer both, most restrictive winning.
One lock order for the ramp. The store used three:
Reservetook domain → scope → reservation → counter,Confirmtook reservation → counter → scope,Releasetook reservation → counter. Three orders over four keys is a deadlock waiting for traffic, and it becomes unavoidable once the gate composes both ledgers into one transaction — that transaction already holds highly contended global budget counters when it reaches the ramp.internal/sendramp/tx.gonow holds the single order and the pool-owning methods are thin wrappers, so there is no second implementation to drift.Composition (
internal/sendingpolicy/ramp.go):SettleProvidermoves the ramp: acceptance advances a qualified day, permanent rejection releases, retryable/ambiguous leaves the reservation standing.CancelAttemptreleases both ledgers;DeferAttemptstill releases only the budget, so a rate-deferred message cannot re-qualify a stage it already qualified.Operational risk
Disabled mode is genuinely pass-through: no scope row, no counter, and no
exemptwrite. That last one matters — writingexemptwhile the ramp is off would permanently grandfather every domain that sent during the disabled window, and the phase-3 activation would find nothing left to ramp. Production ships in exactly this state (ramp_enabled: false).Test plan
go test -p 1 -race -count=1 ./internal/sendingpolicy ./internal/sendramp ./internal/config ./cmd/e2amake fmt-check,git diff --check, synthetic-data scaninternal/agentoutreach-hook failures were baselined against cleanorigin/mainand fail there too — pre-existing, not from this changeFixture note worth keeping: the ramp ledger is keyed by registrable domain, so
ramp-1.example.testandramp-2.example.testare one scope. The first version of these tests read the hostname key, found empty rows, and would have passed for the wrong reason. Each fixture domain is now its own eTLD+1, and the one test that is about sharing builds two hostnames under a single registrable domain deliberately.🤖 Generated with Claude Code
https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX