# Opted-out numbers never contacted even within the same processing batch

- **Pattern:** `ab-000564` (`booking-notifications-reminders.sms.optout-atomic`)
- **Severity:** critical
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000564
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000564)

## Why it matters

A batch reminder job that fetches the recipient list at the top of the function and then loops through sends creates a TCPA race window: a customer can send STOP between the initial query and their position in the loop. CWE-362 (race condition) is the exact failure mode — the opt-out write and the in-flight batch read operate on shared state without synchronization. Under TCPA (47 U.S.C. § 227), intent to comply is not a defense; the message that arrives after a STOP still carries the per-message penalty. A pre-cached Set of opted-out numbers becomes stale the moment the STOP is processed, which on a high-volume system can be milliseconds into the batch run.

## Severity rationale

Critical because the race window between recipient list fetch and per-recipient send can result in TCPA violations even when an opt-out record exists in the database.

## Remediation

Replace any cached opt-out list with a fresh database query per recipient inside the send loop. The extra round-trip per booking is negligible compared to the per-message TCPA exposure:

```ts
// src/jobs/reminders.ts
async function sendReminders() {
  const dueBookings = await db.bookings.findMany({
    where: { reminder_due_at: { lte: new Date() }, reminder_sent: false },
  })

  for (const booking of dueBookings) {
    // Fresh DB query per recipient — never a cached list
    const optOut = await db.smsOptOut.findUnique({
      where: { phone_number: booking.customer_phone },
    })
    if (optOut?.opted_out_at && !optOut.opted_in_at) {
      await db.bookings.update({ where: { id: booking.id }, data: { reminder_sent: true } })
      continue
    }
    await smsQueue.add('send-reminder', { bookingId: booking.id })
  }
}
```

## Detection

- **ID:** `optout-atomic`
- **Severity:** `critical`
- **What to look for:** Find any batch SMS processing code — reminder jobs, bulk notification jobs, or loops that process multiple bookings. In each batch processor, verify the opt-out check happens BEFORE the SMS is queued or sent, not after. Look for race conditions: (1) Does the batch job pre-fetch a list of recipients then loop through sending? If so, an opt-out received between fetch and send would be missed. (2) Is the opt-out check inside the loop (good) or only at the batch level (bad)? (3) Is there a database-level constraint or atomic query that prevents sending to opted-out numbers?
- **Pass criteria:** Enumerate all batch SMS processors. ALL of the following: (1) 100% of batch processors check opt-out status per-recipient INSIDE the loop, immediately before queuing the SMS — quote the batch function name and the location of the opt-out check relative to the send call. A pre-cached opt-out list does NOT count as pass if it could become stale during batch execution. (2) No window exists where an opt-out could be processed after the recipient list is fetched but before their SMS is sent, OR the opt-out check uses a fresh database query per recipient (not a cached list).
- **Fail criteria:** Batch job pre-fetches recipients and does not re-check opt-out before each send. Opt-out check happens outside the per-recipient loop. Race condition exists where a newly opted-out number could still receive an SMS within the same batch execution. No batch processing code exists but the system sends SMS in loops without per-iteration opt-out checks.
- **Skip (N/A) when:** SMS is not offered (same criteria as sms-optout-persisted skip).
- **Detail on fail:** Quote the batch function and describe the race condition. Example: `"sendDueReminders() at src/jobs/reminders.ts:15 queries all bookings due for reminders, then loops through each. The opt-out check is inside the loop (line 28), BUT it reads from a pre-fetched Set<string> populated at line 17 — a STOP received after line 17 but before line 28 would not be reflected."`.
- **Remediation:** Check opt-out with a fresh database query per recipient inside the send loop:

  ```ts
  // src/jobs/reminders.ts
  async function sendReminders() {
    const dueBookings = await db.bookings.find({
      where: { reminder_due_at: { lte: new Date() }, reminder_sent: false }
    })

    for (const booking of dueBookings) {
      // Fresh DB query per recipient — not a cached list
      const isOptedOut = await db.smsOptOut.findUnique({
        where: { phone_number: booking.customer_phone }
      })

      if (isOptedOut?.opted_out_at && !isOptedOut?.opted_in_at) {
        await db.bookings.update({ where: { id: booking.id }, data: { reminder_sent: true } })
        continue
      }

      await smsQueue.add('send-reminder', { bookingId: booking.id })
    }
  }
  ```
- **Cross-reference:** Email/SMS Compliance audit checks TCPA batch compliance. Database Audit checks for stale-read vulnerabilities. Booking Flow & Lifecycle audit checks booking query patterns for similar race conditions.

## External references

- external TCPA-47-USC-227 — https://www.law.cornell.edu/uscode/text/47/227
- cwe CWE-362
- gdpr Art. 21

Taxons: privacy-consent, data-integrity

HTML version: https://auditbuffet.com/patterns/ab-000564
