Skip to main content

Context

This is the feature that exists because of a specific research finding: Resend’s rate limit is per team, applied across every API key — so every tenant shares one budget. Without isolation, one marketing blast starves every other tenant’s OTPs, invitations and receipts. A platform where one customer’s campaign breaks another customer’s login is not multi-tenant in any sense that matters. ADR-015’s answer is two rails per channel with strict priority, plus token buckets at two levels: a global bucket per provider matching the provider’s real limit, and a per-tenant bucket for fairness among tenants. The global bucket is the one that reflects physics; the per-tenant bucket is the one that reflects fairness, and both are needed.

Scope (normative)

  • Two queues per channel: notif.{channel}.transactional and notif.{channel}.marketing.
  • Worker harness on the shared idempotency and retry infrastructure (FS-CORE-0007).
  • Token buckets in Upstash: global per provider, and per tenant.
  • Marketing workers yielding when transactional depth crosses a threshold.
  • Batch dispatch for email, using the provider’s batch endpoint.
  • Backpressure and observable queue depth per rail.

Non-scope (normative)

  • The adapters themselves — FS-MSG-0006. Dispatch decides when and how fast; adapters deliver.
  • Deciding whether to send — FS-MSG-0001.
  • QueuePort and the generic consumer harness, which are core and TS-002.

Behaviour (normative)

  1. Every channel has exactly two rails. Category maps to rail: transactional and product to the transactional rail, marketing to the marketing rail.
  2. Transactional always outranks marketing. When transactional depth crosses the threshold, marketing workers yield — they finish the job in hand and stop pulling until depth recovers. Yielding, not draining: a marketing worker that dies loses its progress.
  3. Two token buckets, both consulted: a global bucket per provider matching the provider’s real limit, and a per-tenant bucket for fair use. The global one is physics; the per-tenant one is fairness.
  4. On a provider 429, back off, never drop. A dropped marketing message is a lost campaign; a dropped transactional message is a customer who cannot log in.
  5. Email blasts use the provider’s batch endpoint exclusively (100 per request for Resend). Sending a blast one request at a time exhausts the global bucket for everyone.
  6. Contacts are never synced to the provider’s audience feature. Our store is the source of truth; a copy there is a second consent record we cannot govern.
  7. Every worker is idempotent through core.processed_jobs, and exhausted retries land in core.dead_letters with the send id attached.
  8. Queue depth per rail is a published metric with an alert. A transactional rail growing is an incident, not a statistic.
  9. FORBIDDEN: a marketing job on the transactional rail · bypassing the buckets for an “urgent” send · dropping a message on 429.

Data (normative)

API (normative)

Ops-facing. A tenant does not tune our rate limits; they see their own throughput in the console.

Events (normative)

None. Dispatch is mechanism; the send events (FS-MSG-0003) are the record.

Acceptance criteria (normative)

  1. The blast-vs-otp k6 profile: a marketing blast at full throughput while a second tenant’s transactional sends keep their latency budget. This is the profile that proves ADR-015 holds, and it is the acceptance criterion the whole feature exists for.
  2. Marketing workers yield when transactional depth crosses the threshold, and resume after.
  3. A provider 429 causes backoff and zero dropped messages.
  4. Email blasts use the batch endpoint; a single-message-per-request path does not exist.
  5. One tenant exhausting its per-tenant bucket does not consume another tenant’s share.
  6. A duplicate job produces one send.
  7. Exhausted retries land in dead letters with the send id attached.
  8. Negative: no code path writes contacts to the provider’s audience feature.

Execution

Asynchronous pipeline. Workers in backend/workers, one deployment per ADR-015’s isolation argument. Buckets in Upstash, sharing the primitives used for API rate limiting.

Open questions

Changelog

Delivery record

Not implemented yet.