Skip to main content

Source of truth

The canonical store for consent decisions is the consent record table — one row per (tenantId, subjectId, purpose) with a status of granted, revoked, or pending. Read it via the async resolver:
getConsent reads ConsentRecord rows first. If the customer has any rows, those become authoritative and the attribute fallback is ignored. If none exist, it falls back to the legacy customer-attribute keys (consent_marketing, consent_email, etc.) for backward compatibility. To disable the attribute fallback (recommended after a tenant has run the backfill script — see below), either pass { recordCanonical: true } as the fourth argument, or set it once per tenant:
An explicit fourth argument always wins. When omitted, getConsent resolves recordCanonical itself by reading Tenant.settings.consent.recordCanonical for the given tenantId — so the setting applies to every caller, including the Recommend decision path below, without that caller needing to pass anything. The lookup fails open (treated as false, i.e. attribute fallback stays enabled) if the tenant row can’t be read.
Tenant.settings.consent.recordCanonical is not yet exposed in the Settings UI or allowlisted on PUT /api/v1/tenant-settings — set it directly on the tenants.settings JSONB column (e.g. via a one-off script or psql) until an admin-facing control ships.

Enforcement at decision time

Consent is enforced in the Recommend decision path. At inventory load the recommend pipeline resolves consent once with getConsent(tenantId, customerId, attributes) — no fourth argument — and suppresses any candidate whose required channel consent is revoked. Because it runs once at inventory load, it applies regardless of which downstream filter nodes a flow contains. Since this call site never passes options, a tenant that wants attribute fallback disabled must use the per-tenant Tenant.settings.consent.recordCanonical flag described above — passing { recordCanonical: true } inline only matters for callers you control directly (scripts, custom integrations). Each candidate’s channel type is mapped to a required consent key before the check:
Consent enforcement is fail-open by design:
  • A customer with no ConsentRecord is treated as consented — they pass (getConsent returns the all-permissive default).
  • A candidate on a channel type not in the map above is allowed (unknown channel types are not suppressed).
  • If consent resolution itself fails (for example a consent-store DB error), the request keeps all candidates rather than dropping them — availability is prioritized over enforcement. The failure is not silent: it emits a throttled consent_resolution_failed system-health alert (the CRITICAL-ALERT: log convention, at most once per tenant per 5 minutes) and a tamper-evident consent_resolution_failed audit entry recording the tenant, subject, and cause. Operators are paged so the outage is visible even though decisions keep flowing.
Only an explicit revoked (or non-granted) ConsentRecord for the channel’s required consent key suppresses a candidate.
The number of candidates remaining after this filter is recorded on the decision trace as afterConsent. This consent stage is also where the do_not_contact rule’s external dncSource intent is enforced — the per-candidate contact-policy engine does not perform its own external suppression-list lookup.

Purposes recognized

Custom purposes are accepted in the table but ignored by getConsent. Add a mapping in the platform consent helper module if you need a new key.

DSAR / GDPR Article 7 compliance

Every consent change is recorded with grantedAt / revokedAt timestamps and a source field (manual, api, import, backfill). This satisfies Article 7’s “demonstrable consent” requirement: at any point the controller can show the exact moment consent was granted, by what channel, and on what source. DSAR exports include the full ConsentRecord trail for the subject (see DSAR portability).

Backfill from legacy attributes

If your tenant currently stores consent in customer attributes (e.g. consent_marketing: true), run the backfill once to populate ConsentRecord rows:
The script is idempotent: customers who already have ConsentRecord rows are skipped. After backfill, you can flip your application code to pass { recordCanonical: true } to getConsent.

Deprecated: synchronous attribute extraction

The legacy synchronous attribute-based consent helper is preserved for backward compatibility only. New callers must use the async getConsent instead. The deprecated path is wire-flagged: it’ll be removed in a future release once all production callers migrate. Track the deprecation status via the scaffold-coverage audit script.

What’s tested

23 unit tests cover the consent surface:
  • 17 cover the deprecated extract / has / filter helper paths (kept for backward compat).
  • 6 cover the new async getConsent path: consent-record-first read, attribute fallback, recordCanonical flag, third-party mapping, and DB-unavailable graceful degradation.

What ships with this surface

  • Platform consent helper — async getConsent (canonical) plus the deprecated attribute helpers.
  • Consent-helper test suite — full coverage of canonical and legacy paths.
  • Consent-record backfill script — one-time migration for tenants moving off legacy attribute storage.
  • The consent record Prisma model.