Verbatim symptom
Per-row jitter alone collapsed to ~0 once many rows waited# The Jitter That Wasn't There
The first live launch of cold outreach sent at a flat 6-minute gap. Every send, same distance from the last one. Clean, regular, and exactly the shape a warm-up schedule exists to avoid.
Here's how that happened, what I got wrong first, and what actually fixed it. Repo is claude-web-api, fix landed in commit 6f1c26e on 2026-10-20.
Symptom
The line that tipped me off was this one, and I'm quoting it exactly because it stuck in my head: "Per-row jitter alone collapsed to ~0 once many rows waited."
That's the observed behaviour in one sentence. The design said sends should be spaced with a 6-minute minimum gap, plus some random delay of up to 20 minutes per row. So you'd expect messy, human-looking spacing. Some gaps short, some long, nothing you could draw a ruler through.
What we got was a ruler. With a small queue the jitter looked fine, which is the annoying part. It only vanished once lots of rows were waiting, and a first live launch is precisely when lots of rows are waiting. So the one moment the pacing mattered most was the one moment it quietly stopped working.
What we thought it was
My first guess was the random number generator. You know, the usual suspect. Maybe the draw was biased, or it was seeded the same way every run, I mean, or somewhere along the line that upper bound of 20 minutes was just getting clamped. All believable, and all things that really do go wrong in pacing code.
It was believable because the symptom looked like "randomness isn't random enough." A flat gap is what you'd see from a constant, so I went hunting for a constant. I checked the bound, I checked the seeding, I checked whether the jitter was being applied at all.
It was being applied. Every row got its own draw, up to the full 20 minutes. The generator was fine. Chasing it cost time, but it ruled out the easy answer, which is useful in its own way.
Root cause
The bug wasn't in the random draw. It was in what we did with many draws at once.
Each waiting row carried its own random delay of up to 20 minutes, sitting on top of the 6-minute minimum gap. The mailbox logic in src/lib/outreachMailbox.ts:38@6f1c26e^ and the campaign store in src/lib/outreachCampaignStore.ts:97@6f1c26e^ between them decided when the next send went out. And the next send was whichever row became eligible first.
That's the catch. "Earliest of many random draws" is a minimum, and the minimum of N draws tends towards zero as N grows. Think about it with a big pile of rows: somewhere in that pile, one of them drew a tiny delay. With enough rows, almost certainly several did. So the jitter you actually see between consecutive sends isn't a draw from 0 to 20 minutes. It's the smallest draw out of everything waiting, which is near nothing.
Once the jitter shrinks to roughly nothing, you're left with the floor. The 6-minute minimum gap. Send, wait 6 minutes, send, wait 6 minutes. A flat line.
So each row's jitter was perfectly random, and the system's jitter was close to zero. Both statements true at once. That's what made it slippery.
The fix
The fix replaces per-row delays with one next-send time per mailbox. Not one per row. One per mailbox.
That single time is computed from state. It takes the day's remaining warm-up allowance and spreads it across the rest of the send window, so the pacing follows the schedule instead of whatever the queue happens to look like. There's still a 6-minute floor, so no two sends get closer than that. And there's jitter of plus or minus 25%, applied around the spread interval.
The jitter is seeded by the last send. This matters more than it sounds. Because the seed comes from the last send, any check of the same state produces the same answer. Ask twice, get one time. Nothing re-rolls the dice just because something polled the mailbox again, and nothing lets a bigger queue pull the result towards zero.
Queue size no longer touches the spacing at all. That was the whole problem, so removing the dependency is the fix.
Why it happened
This survived review for a boring reason: each piece looked right on its own.
A per-row random delay reads as obviously correct. Randomness per item, bounded, added to a floor. Reviewers (me included) read each row's logic in isolation and it holds up. The failure only exists in the aggregate, across many rows at once, and code review mostly doesn't look at aggregates. It looks at lines.
Testing had the same blind spot. Small queues behave. The jitter looks healthy with a handful of rows, so any test built from a handful of rows passes. The collapse needs volume, and volume is exactly what the first live launch supplied for the first time.
So nobody was careless. The property we cared about, spread-out sends, wasn't a property of any single row. It belonged to the mailbox, and we'd put the randomness on the wrong object.
How to avoid it
If you pace anything with randomness, a few habits transfer.
First, ask which object owns the property you want. If you want irregular gaps between sends, that's a property of the sequence of sends, not of each queued item. Put the randomness where the property lives.
Second, be suspicious of any "earliest of many" logic with random inputs. A min or max over a growing set drifts to an extreme by construction. If the set grows, so does the drift.
Third, make the scheduler deterministic given its state. Seeding from something stable, like the last send, means repeated checks agree and you can actually reason about the output. Randomness that re-rolls on every look is hard to test and easy to bias.
Fourth, test at the scale you'll run at, and assert on the observed gaps, not on the inputs. A test that checks the spacing of real output with a big queue would have caught this. A test that checks each row's delay never would.
The jitter was never missing from the code. It was just drowned out by the queue, right where it mattered.
Every commit, file reference and date above is checked
against this incident's Noctem evidence pack before publication. Evidence:
cold-mailbox-pacing-collapsed-to-flat-six-minutes.