# Granular consent preferences supported

- **Pattern:** `ab-000776` (`compliance-consent-engine.consent-storage.granular-preferences`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000776
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000776)

## Why it matters

GDPR Recital 43 and Article 7 require that consent be specific — a contact agreeing to order confirmations has not agreed to promotional campaigns. ePrivacy Article 5(3) separately governs marketing communications, requiring its own specific consent. CCPA §1798.120 gives consumers the right to opt out of specific categories of data sale and sharing. A single `subscribed` flag collapses distinct legal bases into one undifferentiated bucket, making it impossible to honor scoped opt-outs without silencing the contact entirely. When a contact withdraws consent for marketing but not transactional emails, a system with no scope differentiation either keeps sending marketing or breaks transactional delivery.

## Severity rationale

High because conflating consent scopes violates GDPR Art. 7's specificity requirement and forces a binary all-or-nothing opt-out that breaks transactional email when a contact opts out of marketing.

## Remediation

Add a `scope` parameter to every consent query and enforce it at send time. Update your consent lookup function to check the exact scope before sending any campaign:

```ts
async function hasConsent(
  contactId: string,
  scope: 'marketing' | 'transactional' | 'partner'
): Promise<boolean> {
  const latest = await db
    .selectFrom('consent_records')
    .select('granted')
    .where('contact_id', '=', contactId)
    .where('scope', '=', scope)
    .orderBy('created_at', 'desc')
    .limit(1)
    .executeTakeFirst()

  return latest?.granted === true
}
```

Every campaign send path must call `hasConsent(contactId, scope)` with the specific scope for that campaign type — never check a global `subscribed` flag as a substitute.

## Detection

- **ID:** `granular-preferences`
- **Severity:** `high`
- **What to look for:** Check whether the consent schema distinguishes between different communication types. A single `subscribed` field cannot differentiate between a contact who agreed to transactional emails (order confirmations, password resets) and one who also opted into marketing campaigns or partner offers. Look for a `scope` or `type` column on consent records, or a separate `contact_preferences` table with rows per communication type.
- **Pass criteria:** Consent records support at least 2 distinct scopes (e.g., `transactional` vs. `marketing`). Each scope can be independently granted or revoked. Sending logic checks the specific scope before sending, not just a global flag. Enumerate all scope values used in the codebase and count them — report the count even on pass.
- **Fail criteria:** Only a single global `subscribed` boolean exists with no scope differentiation. Or scope exists in the schema but sending logic ignores it and uses the global flag.
- **Skip (N/A) when:** The application sends only transactional emails (receipts, account notifications) and has no marketing or promotional communication.
- **Detail on fail:** `"Single subscribed flag with no scope — all 37 campaign sends check contacts.subscribed regardless of campaign type"` or `"contact_preferences table has scope column but CampaignService always queries subscribed=true without scope filter"`
- **Remediation:** Add scope to your consent query:

  ```ts
  // Before sending any campaign, verify scope-specific consent
  async function hasConsent(contactId: string, scope: 'marketing' | 'transactional' | 'partner'): Promise<boolean> {
    const latest = await db
      .selectFrom('consent_records')
      .select('granted')
      .where('contact_id', '=', contactId)
      .where('scope', '=', scope)
      .orderBy('created_at', 'desc')
      .limit(1)
      .executeTakeFirst()

    return latest?.granted === true
  }
  ```

## External references

- gdpr Art. 7
- eprivacy Art. 5(3)
- ccpa §1798.120
- gdpr Recital 43

Taxons: privacy-consent

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