# Fail-safe blocks sends to pending-unsubscribe contacts

- **Pattern:** `ab-000783` (`compliance-consent-engine.opt-out-processing.fail-safe-block`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000783
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000783)

## Why it matters

GDPR Article 7(3) and CCPA §1798.120 require opt-outs to be honored — a race condition between async opt-out processing and a campaign send batch is not an acceptable excuse for sending to a contact who withdrew consent. CWE-284 (Improper Access Control) captures the broader class: the system fails to enforce the access control decision (do not send) during the window between request and processing. A contact who unsubscribes at 11:58 PM and a campaign batch that starts at midnight will be sent a campaign if the only guard is the processed consent record, which the async worker has not yet written.

## Severity rationale

High because the race window between an opt-out request and async processing completion is predictable and exploitable by campaign send timing, creating measurable violations of GDPR Art. 7(3) and CAN-SPAM §7704(a)(4).

## Remediation

Write the `optOutStatus: 'pending'` flag synchronously before enqueuing the job, so the pre-send guard can catch pending opt-outs even before the worker runs:

```ts
// src/app/api/unsubscribe/route.ts
export async function POST(req: Request) {
  const { contactId, scope } = await req.json()

  // Synchronous — takes effect immediately on all subsequent pre-send checks
  await db.contact.update({
    where: { id: contactId },
    data: { optOutStatus: 'pending', optOutRequestedAt: new Date() }
  })

  await queue.add('process-optout', { contactId, scope })
  return new Response(null, { status: 202 })
}

// Pre-send guard in src/lib/campaigns/can-send.ts
async function canSendCampaign(contactId: string, scope: string): Promise<boolean> {
  const contact = await db.contact.findUnique({ where: { id: contactId } })
  if (contact?.optOutStatus === 'pending' || contact?.optOutStatus === 'completed') return false
  return hasConsent(contactId, scope)
}
```

The synchronous status write costs one extra database round-trip but eliminates the race window entirely.

## Detection

- **ID:** `fail-safe-block`
- **Severity:** `high`
- **What to look for:** Between when a contact requests an unsubscribe and when the async worker finishes processing it, there is a window where the contact could be picked up by a campaign send. Look for a `pending_unsubscribe` flag, an `opt_out_status` column, or a suppression check in the pre-send path that catches contacts whose opt-out job is queued but not yet processed. Without this, a contact who clicks unsubscribe at 11:58pm and a campaign batch that starts at midnight will send to that contact before the worker runs.
- **Pass criteria:** The opt-out request immediately marks the contact as `pending_unsubscribe` (or equivalent) in a synchronous write before enqueuing the job. The pre-send consent check blocks contacts in `pending_unsubscribe` state. Quote the actual status field name and the values used for the pending state. Count all pre-send guard checks in the send path — at least 1 must check the pending-unsubscribe state.
- **Fail criteria:** The unsubscribe endpoint only enqueues the job with no synchronous status update. The pre-send check only queries consent records — a contact with a queued-but-not-processed opt-out job is indistinguishable from a subscribed contact.
- **Skip (N/A) when:** Opt-out processing is fully synchronous (though this would fail the async check above) or there is no overlap between opt-out processing and campaign sends.
- **Detail on fail:** `"Unsubscribe only enqueues job, no immediate status update — contacts remain sendable until worker completes (average 45min delay)"` or `"Pre-send check queries consent_records only, not opt_out_requests table — pending opt-outs not caught"`
- **Remediation:** Add a synchronous write before enqueuing:

  ```ts
  export async function POST(req: Request) {
    const { contactId, scope } = await req.json()

    // Synchronous write — immediate effect on pre-send checks
    await db.contact.update({
      where: { id: contactId },
      data: { optOutStatus: 'pending', optOutRequestedAt: new Date() }
    })

    // Async job handles cascade, ESP sync, audit logging
    await queue.add('process-optout', { contactId, scope })

    return new Response(null, { status: 202 })
  }

  // Pre-send check includes status check
  async function canSendCampaign(contactId: string, scope: string): Promise<boolean> {
    const contact = await db.contact.findUnique({ where: { id: contactId } })
    if (contact?.optOutStatus === 'pending' || contact?.optOutStatus === 'completed') return false
    // ... rest of consent checks
  }
  ```

## External references

- gdpr Art. 7(3)
- cwe CWE-284
- ccpa §1798.120
- external CAN-SPAM-§7704(a)(4) — https://www.law.cornell.edu/uscode/text/15/7704

Taxons: privacy-consent, data-integrity

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