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}.transactionalandnotif.{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.
QueuePortand the generic consumer harness, which arecoreand TS-002.
Behaviour (normative)
- Every channel has exactly two rails. Category maps to rail:
transactionalandproductto the transactional rail,marketingto the marketing rail. - 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.
- 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.
- 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. - 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.
- 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.
- Every worker is idempotent through
core.processed_jobs, and exhausted retries land incore.dead_letterswith the send id attached. - Queue depth per rail is a published metric with an alert. A transactional rail growing is an incident, not a statistic.
- 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)
- The
blast-vs-otpk6 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. - Marketing workers yield when transactional depth crosses the threshold, and resume after.
- A provider 429 causes backoff and zero dropped messages.
- Email blasts use the batch endpoint; a single-message-per-request path does not exist.
- One tenant exhausting its per-tenant bucket does not consume another tenant’s share.
- A duplicate job produces one send.
- Exhausted retries land in dead letters with the send id attached.
- Negative: no code path writes contacts to the provider’s audience feature.
Execution
Asynchronous pipeline. Workers inbackend/workers, one deployment per ADR-015’s isolation
argument. Buckets in Upstash, sharing the primitives used for API rate limiting.