Skip to content

feat(sending): compose ramp with protection gate - #992

Merged
jiashuoz merged 4 commits into
mainfrom
feat/sending-protection-ramp
Sep 5, 2026
Merged

feat(sending): compose ramp with protection gate#992
jiashuoz merged 4 commits into
mainfrom
feat/sending-protection-ramp

Conversation

@jiashuoz

@jiashuoz jiashuoz commented Sep 3, 2026

Copy link
Copy Markdown
Member

Summary

B4 of the sending-protection plan (Task 4). Stacked on #990 — base is feat/sending-protection-gate, not main, because this slice needs B3's Gate. 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: 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 — that transaction already holds highly contended global budget counters when it reaches the ramp. internal/sendramp/tx.go now 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):

  • Probation is the ramp's answer now. 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. This is the subtlest thing in the PR and the comment says so.
  • The ramp is authorized last. A ramp hold therefore arrives with budget units already taken, and those are released before returning — keeping them would charge an account for a send its own domain was not allowed to make.
  • SettleProvider moves the ramp: acceptance advances a qualified day, permanent rejection releases, retryable/ambiguous leaves the reservation standing.
  • CancelAttempt releases both ledgers; DeferAttempt still 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 exempt write. That last one matters — writing exempt while 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/e2a
  • 264 tests in the package plus the existing ramp suite: 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-vs-cancel, subdomain scope sharing, Free-plan stage-two block with progress retained
  • make fmt-check, git diff --check, synthetic-data scan
  • Two internal/agent outreach-hook failures were baselined against clean origin/main and fail there too — pre-existing, not from this change

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.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

@jiashuoz
jiashuoz changed the base branch from feat/sending-protection-gate to main September 3, 2026 22:48
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
jiashuoz force-pushed the feat/sending-protection-ramp branch from 2dd6649 to 26c96b9 Compare September 4, 2026 01:19
jiashuoz and others added 3 commits September 3, 2026 19:48
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
@jiashuoz
jiashuoz merged commit 30089b9 into main Sep 5, 2026
29 checks passed
@jiashuoz
jiashuoz deleted the feat/sending-protection-ramp branch September 5, 2026 04:12
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