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.
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.
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:
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.
ID: compliance-consent-engine.consent-storage.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:
// 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
}