# Opt-out processed within regulatory window

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

## Why it matters

CAN-SPAM §7704(a)(4) gives you 10 business days to honor an opt-out request — but without a `processed_at` timestamp, you cannot prove you met that window for any specific request. GDPR Article 7(3) requires withdrawal to be honored without delay. CCPA §1798.120 imposes similar obligations. CWE-778 (Insufficient Logging) applies: if opt-out processing timing is not measured and recorded, you have no way to detect when a queue backlog is pushing processing into violation territory, and no evidence of compliance when a regulator asks. The absence of timestamps turns a solvable reliability problem into an undetectable and undefendable one.

## Severity rationale

Critical because without `processed_at` timestamps and queue depth alerting, the system cannot demonstrate CAN-SPAM §7704(a)(4) compliance for any specific opt-out request, and queue failures accumulate silently until a regulator asks for evidence.

## Remediation

Add `processed_at` and `processing_ms` fields to opt-out records and write them at worker completion. Set a queue monitoring alert at 2 hours — well inside the 10-business-day CAN-SPAM ceiling:

```ts
// In your opt-out worker (src/workers/optout.ts)
queue.process('process-optout', async (job) => {
  const { contactId, scope, requestedAt, recordId } = job.data

  // ... suppression cascade ...

  await db.optOutRecord.update({
    where: { id: recordId },
    data: {
      processedAt: new Date(),
      processingMs: Date.now() - new Date(requestedAt).getTime()
    }
  })
})
```

Add a queue depth alert in your monitoring stack (Datadog, Grafana, BullMQ's `getWaiting()`) that fires if any opt-out job is older than 2 hours without processing. Document the alert threshold alongside the retention constants in `src/lib/compliance/constants.ts`.

## Detection

- **ID:** `regulatory-window`
- **Severity:** `critical`
- **What to look for:** CAN-SPAM requires unsubscribe requests to be honored within 10 business days. This check verifies that the system has a measurable, monitored processing window. Look for: SLA monitoring on the opt-out queue (alerts if jobs sit unprocessed beyond a threshold), a `processed_at` timestamp on opt-out records (to measure actual processing time), and monitoring/alerting for queue backlog. Also check if the fail-safe (below) prevents sends during the processing window regardless of processing speed.
- **Pass criteria:** The opt-out pipeline records a `processed_at` timestamp. The system has queue monitoring that alerts if jobs exceed a processing threshold (e.g., no more than 1 hour). The fail-safe blocks sends to contacts in `pending-unsubscribe` state regardless of processing speed. Count all timestamp fields on the opt-out record — at least 2 must exist (requested_at and processed_at).
- **Fail criteria:** No `processed_at` timestamp on opt-out records — there is no way to measure actual processing time. No queue monitoring or alerting. No fail-safe to prevent sends during processing delay.
- **Skip (N/A) when:** No email sending; or the application relies entirely on the ESP's own unsubscribe mechanism with no custom opt-out processing.
- **Detail on fail:** `"Opt-out jobs have no processed_at timestamp — compliance window cannot be measured or demonstrated"` or `"No queue depth monitoring — if workers crash, opt-outs silently accumulate with no alert"`
- **Remediation:** Record timestamps and add queue monitoring:

  ```ts
  // Record both requested_at and processed_at
  interface OptOutRecord {
    contactId: string
    scope: string
    requestedAt: Date    // when the user clicked unsubscribe
    processedAt?: Date   // when the system completed suppression cascade
    processingMs?: number
  }

  // In your worker, after completing all steps:
  await db.optOutRecord.update({
    where: { id: job.data.recordId },
    data: {
      processedAt: new Date(),
      processingMs: Date.now() - new Date(job.data.requestedAt).getTime()
    }
  })
  ```

  Set a queue depth alert: if any job is older than 2 hours without processing, fire an alert. Keep this threshold well within the 10-business-day CAN-SPAM window.

## External references

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

Taxons: regulatory-conformance, observability

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