> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kaireonai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Consent management

> Consent records are the canonical source of truth for customer consent. Legacy attribute-based consent reads remain as a backward-compatibility fallback.

## 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:

```ts theme={null}
import { getConsent } from "@/lib/consent";

const consent = await getConsent(tenantId, subjectId, customerAttributes);
// consent.marketing, consent.email, consent.sms, consent.push,
// consent.phone, consent.thirdParty
```

`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:

```ts theme={null}
// Explicit, per-call override
const consent = await getConsent(tenantId, subjectId, attributes, {
  recordCanonical: true,
});
```

```json theme={null}
// Per-tenant, on the Tenant row (Tenant.settings JSONB — same bag
// /api/v1/tenant-settings reads/writes for other toggles)
{ "consent": { "recordCanonical": true } }
```

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.

<Note>
  `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.
</Note>

## 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:

| Channel type                              | Required consent key |
| ----------------------------------------- | -------------------- |
| `email`                                   | `email`              |
| `sms`                                     | `sms`                |
| `push`                                    | `push`               |
| `phone`                                   | `phone`              |
| `direct_mail`, `display`, `in_app`, `web` | `marketing`          |

<Note>
  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.
</Note>

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`](/decisioning/contact-policies#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

| Consent record purpose | Maps to ConsentStatus key |
| ---------------------- | ------------------------- |
| `marketing`            | `marketing`               |
| `email`                | `email`                   |
| `sms`                  | `sms`                     |
| `push`                 | `push`                    |
| `phone`                | `phone`                   |
| `third_party`          | `thirdParty`              |

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](/governance-security/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:

```bash theme={null}
cd platform
npx tsx ../tools/scripts/backfill-consent-records.ts --tenant <tenantId>          # dry-run by default
npx tsx ../tools/scripts/backfill-consent-records.ts --tenant <tenantId> --apply  # real insert
```

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.
