# Consent modes

> Personalized, cohort, non-profiled: what each mode stores and what it never stores.

Source: https://www.patterna.nl/docs/consent-modes

Every request to Patterna carries a mode. The mode decides what Patterna may store about the person and which signals it may learn from. Your backend picks the mode from the consent the user gave you. Patterna does not ask the user anything itself.

## The three modes at a glance

| | `personalized` | `cohort` | `non_profiled` |
|---|---|---|---|
| Identifier sent | your random per-tenant `user_id` | your random per-tenant `user_id` | a rotating `session_id` only |
| User row in Patterna | yes | yes | no |
| Ranking uses | the user's own behaviour, declared attributes, context | declared goals and context | context only |
| Seen filter | from the user's history | from the impression log only | no user history |
| Events used for learning | yes | no: learning jobs exclude cohort users' events | stored with a session id only, for the global policy |
| Holdout arm | sticky per user | sticky per user | from a hash of the session id |
| Shown in the ledger | yes | yes | yes |

## personalized

The user agreed to recommendations based on what they read and do. Patterna keeps a user row with the random id, the mode, a persona and declared attributes (goals, activity level, stress level, wearable, supplement use, premium flag, locale). It learns a per-user model on top of the per-persona one.

What it never stores, in any mode: email, name, IP, device id, free text, coordinates, or any health value. See [Data we never store](https://www.patterna.nl/docs/data-we-never-store).

## cohort

The user agreed to recommendations based on what they told you, not on what they do. Patterna ranks from declared goals only. There is no behavioural affinity and no seen-filter history beyond the impression log. The learning jobs leave cohort users' events out.

Cohort is a choice you offer. If you do not offer it, nothing maps to cohort and a missing consent means `non_profiled`. Cohort ranking reads the goals you send with `PUT /users/{user_id}/profile`. Send the mode with the profile for a new user: a profile without `mode` keeps an existing user's mode, but for an id Patterna does not know it is refused with `422 mode_required` (and for a user who opted out with `422 mode_conflict`), so a profile write can never create consent by accident.

## non_profiled

The user said no, or never said yes. The request must carry a `session_id` and no `user_id`. Patterna never touches the users table for it. It still serves a good list: a global policy learned from context only (`locale`, `app_version`, `platform`, `hour_bucket`).

The holdout still covers these users. The arm comes from a hash of the session id, so the ledger includes people who opted out.

Rotate the session id. Mint 128 random bits and rotate them, for example when the app returns to the foreground after 30 minutes. It is not a device id and is never persisted.

Because the session id rotates, one person can land in both holdout arms over a day. Send an `assignment_unit` on `non_profiled` requests (a 64-hex hash you compute from your own user id) used only to keep that person in one arm; it is never stored raw and never used for ranking. See [Identity across connectors](https://www.patterna.nl/docs/identity-across-connectors#assignment-unit).

## Mapping your consent records to a mode

Patterna has three modes; you decide which of your consent records maps to which. Write the mapping down once and use it in every place that calls Patterna: the request path, the consent-change handler and any import.

A worked example, from a health app that already asks two consent questions:

| Your consent record | Mode |
|---|---|
| `personalized_offers` granted ("Allow personalized offers and recommendations") | `personalized` |
| `analytics` granted, `personalized_offers` not granted | `cohort` |
| neither granted, or no consent row at all | `non_profiled` |

Rules that keep it honest:

- **Missing means no.** A user without a consent row is `non_profiled`. Never treat silence as a yes.
- **Personalization needs a consent that names it.** Analytics consent alone is not a basis for profiling on behaviour; that is why it maps to `cohort` (declared goals only), not to `personalized`.
- **One mapping everywhere.** The request path, the consent-change handler and any one-off import from your database use the same table.

### What a dedicated `recommendations` consent would add

You can also add a new consent type that asks only about recommendations. Compared with reusing an existing offers consent, it gives:

- a purpose that names recommendations only, separate from marketing offers, which is a cleaner basis for profiling;
- a toggle you can explain in one sentence next to the lists it controls.

The cost: nobody has given it yet. Everyone starts `non_profiled` until they answer, so you need a prompt after onboarding and an opt-in rate to watch. The choice belongs to your privacy owner; Patterna works the same either way.

## Measured lists and analytics consent

Interaction reporting (seen, tap, dismiss) follows the user's **analytics** consent: if a user did not allow usage analytics, your app does not report what they saw or tapped. That is independent of the mode, which governs what Patterna may store and rank on.

If you stop reporting for those users without telling Patterna, their slates still count as served with zero taps in both arms. The difference between arms stays unbiased but is diluted, and the tap rate is meaningless as an absolute number.

Send `context.measured: false` on the recommendation request for a user who did not allow analytics. Serving is unchanged. Unmeasured slates are left out of the ledger, the power curve and learning, in both arms. Consent is given before the arm is drawn, so leaving them out is valid. `measured` defaults to `true`.

## Changing the mode

Tell Patterna when consent changes. The call takes the ingest key:

```curl
curl -sS -X PUT "https://patterna-api.orven.am/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe/mode" \
  -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"mode": "non_profiled"}'
```

Switching to `non_profiled` is an opt-out, with the same semantics as a deletion of the profile. In the request the user row (profile and affinities) is deleted, Patterna stores a salted hash of the id as the opt-out marker, and it files a deletion request that holds only that salted hash. The hourly `deletions` job then unlinks the user's impressions (the id and the logged candidates are cleared; the salted `unit_hash` stays, so ledger rows do not change after the fact), keeps their interactions (they belong to the slate), and deletes the user's events, holdout assignments and precomputed shortlist. Serve honours the marker at once: a `personalized` or `cohort` request for that id is served as `non_profiled` and logged without the id, keeping the unit it had and, unless the holdout is stratified by persona (the user has no persona any more), the arm; the audit log records `serve.optout_downgrade` once per day, without the id. Setting the mode to `personalized` or `cohort` again (with this call, or a profile that carries `mode`) clears the marker. The nightly retrain clears the models.

After the call, forget your mapping for this user. A later opt-in then mints a fresh id that cannot be joined to the old history.

**Opt-out versus deletion.** Both leave impressions in the ledger, unlinked, and neither deletes them. An opt-out keeps a hashed marker so serve stays non-profiled for that id. A deletion (`DELETE /users/{user_id}`) also removes that marker and keeps only a hashed record that a deletion happened. See [Handle a deletion request](https://www.patterna.nl/docs/handle-a-deletion-request).

The endpoint accepts the ingest key (and the admin key). Both SDKs send the admin key when you configure one and the ingest key otherwise.

## The SDKs enforce the mode

Both SDKs refuse a request before anything is sent when:

- the mode is missing or unknown (`consent_required`);
- a `non_profiled` request carries a `user_id` or lacks a `session_id` (`consent_violation`);
- a user id looks like an email or an identity-provider subject such as `auth0|...`.

These are bugs in the caller. `withFallback` and `RecommendWithFallback` rethrow them instead of hiding them behind the fallback list, because a consent bug is a privacy bug.

The engine checks too: a `non_profiled` request with a `user_id` gets `422 mode_conflict`, and a `personalized` or `cohort` request without one gets `400`.

## Choosing the default

Set the default mode in Settings → Consent. A safe default is `non_profiled`: everyone starts there and personalization begins per user at opt-in. That makes the opt-in rate a number to watch in Audiences.

## Read next

- [Data we never store](https://www.patterna.nl/docs/data-we-never-store)
- [Identity across connectors](https://www.patterna.nl/docs/identity-across-connectors)
- [Handle a deletion request](https://www.patterna.nl/docs/handle-a-deletion-request)
