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.
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.
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:
// 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 })
}
}
ID: booking-notifications-reminders.sms.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:
// 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.