How cap enforcement works

TL;DR

WarmySender enforces per-campaign daily caps using three layers that compose together so the platform cannot over-send under any failure mode:

  1. Pre-enqueue peek — before creating a new campaign_send_jobs row, the scheduler reads the per-campaign cap counter. If the cap is full, the enrollment is deferred to next UTC midnight and no job row is created. (Added 2026-05-06.)
  2. Atomic Redis reserve — when the worker picks up a job, it atomically increments a Redis counter via Lua. If the increment would exceed the cap, the increment is rolled back and the worker expires the job.
  3. Per-account ramp ceiling — independently, each LinkedIn account has its own daily ramp guard that grows over a 4-6 week period from a low starting cap to the documented LinkedIn soft cap (~80-100/day for paid accounts).

WarmySender is a 4-pillar platform — Cold Emailing, Email Warmup, LinkedIn Outreach, and Multichannel sequences. Cap enforcement applies to LinkedIn campaigns; cold email campaigns use a separate per-mailbox daily counter.

Why three layers and not just one?

Each layer guards against a different failure mode:

The composition is multiplicative: a send fires only if all three layers allow it. Any one layer can block; any one layer being too lenient is corrected by the next layer down.

Worked example — Selva PIPL Connection (2026-05-06 incident)

This is the actual production incident that motivated the V13 P0 #1 fix.

Setup. Campaign cap = 20 invites/day. Mid-morning the cap had filled (20 invites successfully fired). The next scheduler tick still found 30 prospects in scheduled state with next_scheduled_at in the past.

Pre-V13 behaviour.

  1. Scheduler tick at 11:00 UTC creates 30 new campaign_send_jobs rows (one per due enrollment).
  2. Worker picks up each job, calls atomic Redis reserve: 21st reservation rolled back, job expired with last_error = "Campaign daily limit reached (21/20)".
  3. Scheduler tick at 11:17 UTC sees the same 30 enrollments still scheduled (the unique partial index covers pending+processing, not expired). It creates 30 NEW jobs.
  4. Workers expire those 30 again. The cycle repeats every ~17 minutes.
  5. By end of day: 604 jobs created, ~20 succeeded, ~580 expired by cap.

Post-V13 behaviour.

  1. Scheduler tick at 11:00 UTC peeks the per-campaign cap counter via Redis. Sees 20/20. Defers all 30 enrollments to next UTC midnight + jitter. Zero jobs created.
  2. If the Redis peek is unavailable (cache eviction, outage), the expired-backoff guard catches the rest: any enrollment that has a recent (last 4 hours) expired job with cap-class last_error is also deferred to next midnight.
  3. End of day: ~20 jobs created (the cap), ~5 buffer for race conditions where the peek says "room" and the worker discovers the cap is full at reserve time.

Net waste reduction: 604 → ~25 jobs/day (96% reduction in DB writes for cap-bound campaigns). At platform scale this saves thousands of DB inserts/day across all customers.

Follow-up priority — owed messages fire before fresh invites

(Added 2026-05-06 with V14.)

When a campaign uses the shared daily cap (most users — i.e. you have NOT opted into per-action-type cap split), invites and follow-up messages compete for the same daily quota. Without prioritization, fresh invites can consume 100% of the cap on a busy day and leave accepted prospects' follow-up messages starved until the UTC midnight reset. We ran into this in production on 2026-05-06: a customer's campaign with daily_send_limit = 20 sent 21 invites in the morning and left 6 accepted prospects waiting days for their follow-up messages to fire.

The fix has two parts that compose:

  1. Tick-loop ordering — when the scheduler picks prospects to enqueue per tick, it sorts by priority class:
    1. Priority 0 — accepted prospects whose follow-up message has not been sent yet (linkedin_accepted_at IS NOT NULL AND linkedin_last_messaged_at IS NULL). Within this class, oldest accept first (FIFO over your existing relationships).
    2. Priority 1 — prospects with a late-accept stamp (late_accept_observed_at IS NOT NULL). The V12 heal cohort that already missed a wait-accept window.
    3. Priority 2 — fresh invites and everything else, ordered by their authored next_scheduled_at (legacy semantics preserved).
  2. Soft sub-budget for follow-ups — when peeking the daily-cap counter for a fresh INVITE, the scheduler subtracts up to 60% of the cap as a soft reserve for any pending follow-ups, capped at the actual count of pending follow-ups. Follow-up messages bypass this reserve entirely (those slots are FOR them). The 40% spillover is preserved for fresh invites so a campaign with many accepts still ramps up new prospects, just at a slower rate.

Worked example. Campaign cap = 10/day. 8 accepted prospects need follow-up messages. 100 fresh invites are due in the queue.

Account safety. The cap itself is never bypassed — only the per-priority allocation WITHIN the cap is reordered. The atomic Redis reserve and the per-account ramp ceiling remain the only paths that consume cap budget. In the worst case, the soft reserve mis-defers a fresh invite to tomorrow, which is the same direction every other defer in the system goes (toward send-less, never send-more).

When this does NOT apply. If you have opted into the per-action-type cap split (each action has its own daily limit), invites and follow-ups already live in separate buckets and don't compete with each other. The soft reserve is only relevant on the shared bucket.

What you see. When the soft reserve is the gate (vs the raw cap being full), the prospect detail page shows Campaign daily cap full pre-enqueue (4/10 other (follow-up reserve 6)) — deferred to next UTC midnight. The annotation makes it clear the platform is holding budget for owed follow-ups, not that you're truly at cap. Per-tick logs also include priority_dist=[p0_post_accept=N p1_late_accept=N p2_fresh=N] so ops can verify prioritization is firing on busy campaigns.

Account safety — the peek can never over-send

The pre-enqueue peek is read-only. It reads the current cap counter and compares to the cap. It does NOT increment anything. The atomic increment happens only inside the worker, AFTER the worker has picked up a job and is about to call the LinkedIn API.

This means:

The composition is what CLAUDE.md calls "account safety always wins" — the system can mis-defer (rare, harmless) but cannot over-send (would risk LinkedIn account ban).

What you see in the dashboard when caps are hit

When your campaign's per-action cap is full for the day, you'll see one or more of:

If you want to send more per day, you can either:

  1. Lift the campaign cap (Daily limit on the campaign edit page) — but stay under your per-account ramp ceiling, otherwise the per-account ramp will be the new gate.
  2. Open Cap split (advanced) and set explicit per-action caps. See How invite, message, and InMail caps work together for the per-action-bucket semantics.
  3. Add a second LinkedIn account to the campaign to spread load across multiple per-account ramps.

When do caps reset?

All cap counters reset at 00:00 UTC. The scheduler reschedules deferred enrollments to next UTC midnight + 0-30 minutes of random jitter — the jitter spreads the post-midnight burst across half an hour so the engine is not slammed at exactly 00:00 UTC every night. This is the same dawn-herd-spreading pattern used by the email-side daily-cap deferral.

Per-account ramp counters also reset at 00:00 UTC and align with how LinkedIn's own per-account counters reset on its side. This alignment is intentional — having WarmySender's counters drift from LinkedIn's would create false-positive "you have room" decisions when LinkedIn has already counted you out.

Frequently asked questions

Why doesn't the scheduler just skip due enrollments when the cap is full instead of creating jobs?

It now does — that's the V13 P0 #1 fix described on this page. Pre-V13, the scheduler had no awareness of the per-campaign cap counter at the enqueue stage; the only cap enforcement was inside the worker. Post-V13, the pre-enqueue peek catches the steady-state cap-full case and defers cleanly without creating any DB rows.

My campaign created 600+ jobs in one day on 2026-05-06 — is my data safe?

Yes. The expired jobs are metadata rows in the campaign_send_jobs ledger; they do not affect prospect state, campaign sequence progress, or send volume. Your "Sent" count is the real number of LinkedIn invites that fired (capped at 20 in the Selva incident). The 580+ expired rows are visible in audit logs but did not consume LinkedIn account budget. After the V13 fix, you'll see ~25 jobs/day instead of 600 on cap-bound campaigns.

Will the pre-enqueue peek slow down my campaigns?

No. The peek is one Redis GET per due enrollment per scheduler tick — about 5-15 milliseconds round-trip. Compared to the alternative (INSERT a job row + worker pickup + atomic reserve + DECR-on-overshoot, then UPDATE the row to expired), the peek is roughly 100x cheaper. For non-cap-bound campaigns the peek returns "room available" immediately and the scheduler proceeds to create the job as before — the peek path has zero impact on throughput.

What if Redis is down — does cap enforcement stop working?

No. The peek returns atOrOverCap=false when Redis is unavailable, so the scheduler proceeds to create the job as before. The worker's atomic reserve has a fail-CLOSED PG fallback that still gates correctly during Redis outages by querying campaign_events directly. The over-send risk during a Redis outage is bounded by worker tick rate, not by Redis health. Per CLAUDE.md, account safety always wins — the system fails toward "send less, not more" on every layer.

Why is there a 4-hour expired-backoff window? Why not shorter or longer?

Four hours covers the day-quarter — if a job expired in the last 4 hours with a cap-class error, we have hard evidence we hit cap recently and tomorrow is the next plausible time we'd have room. Shorter (e.g. 30 minutes) would re-enable the create→expire cycle once the 30-min anti-churn check inside enqueueToPhase2 elapsed. Longer (e.g. 24 hours) would be redundant with the per-day cap reset at UTC midnight. Four hours is the sweet spot that catches the steady-state case without over-deferring on transient cap blips.

Can I disable the pre-enqueue peek for my campaign?

No. The peek is an account-safety feature; disabling it would re-enable the create→expire churn. There's no user-visible setting to turn it off because the peek does not change campaign behaviour — only how the engine implements that behaviour. If your campaign appears to be sending less than expected, check the campaign's Daily limit setting and your per-account ramp progress on the LinkedIn accounts page.

How does this interact with the cap split (per-action-type limits)?

The peek uses the same resolveCapForAction routing as the worker. So if you have daily_invite_limit = 80 set explicitly, the peek will check the send_invite bucket (cap=80). If the split columns are NULL, the peek falls back to the legacy combined bucket (cap = daily_send_limit). This bucket-routing parity is critical — if the peek used a different bucket from the worker, the peek and reserve would disagree and we'd re-introduce the over-send window. See How invite, message, and InMail caps work together for the per-action-bucket semantics.