# Data we never store

> PII rules, allowlists per event, and how deletion and export reach every row and every model.

Source: https://www.patterna.nl/docs/data-we-never-store

Patterna is built so that personal data cannot enter, rather than being cleaned up later. Ranking works on random ids, item ids, positions and a little context. Anything else is rejected at the door or dropped by the connector before a row is written.

## Never stored, in any mode

- email addresses
- names
- IP addresses
- device ids
- free text written by users (search queries, coach questions, community posts)
- coordinates
- any health value (measurements, symptoms, cycle phase, any Cyclus value)

Your identity provider's subject (such as `auth0|...`) never reaches Patterna either. You mint a random id per user and keep the mapping on your side. How that works across the backend, PostHog, GA4 and Shopify: [Identity across connectors](https://www.patterna.nl/docs/identity-across-connectors).

## How it is enforced

| Layer | Rule |
|---|---|
| SDKs | Refuse email-shaped and `provider|id`-shaped user ids, and a user id on a `non_profiled` request, before anything is sent. The id is never put in the error message. |
| Event envelope | `context` accepts only `locale`, `app_version`, `platform`, `hour_bucket`, `weekday`, `region`, `subscription_tier`, `tenure_days` (a coarse region name from your own list, never a location). Any other key is dropped and counted; a personal-data name rejects the event. |
| Event props | An allowlist per event type. An event with a PII field name (such as `email` or `name`) in `props` or `context`, or an email or `provider|id` user id, is rejected on its own with code `pii_rejected` and the offending paths in `fields` (never the values); the rest of the batch is accepted. Any other field outside the allowlist is dropped before the row is written and counted in the response's `dropped_fields`. |
| User ids | Ids containing `@` or whitespace, or longer than 128 characters, are refused: `400` on the user endpoints, and the event is rejected in an events batch. |
| Catalog | Item attributes with `email`, `name` or `ip` keys are rejected with `422 pii_rejected`. Contact details in item text (emails, phone numbers) are scrubbed at upsert, before storage or any prompt. |
| Connectors | Drop what they do not need before writing. PostHog keeps no raw distinct id: an id Patterna already minted (`u_…`) is kept, any other is replaced by a keyed hash under the tenant's `PATTERNA_ID_SECRET`, a secret Patterna holds (so make the distinct id your own random Patterna id, never the identity-provider subject) and drops person properties, `$set`, IP and every `$` property except OS, app version and locale. GA4 uses `user_pseudo_id` only as an input to hash session and event ids. Shopify queries select no customer fields. |

Where users type, Patterna gets a derivative, never the text: `search_performed` carries a `query_hash`, `coach_question` carries a `topic_cluster`, and `order_placed` carries an `order_id_hash`.

Member-written names of communities and events are imported with an empty title by default. The panel shows them as "Member community c_...".

## Item text

To tag an item, the jobs need its body text. The text is stored only until enrichment is done:

- The catalog keeps the body in a separate table and marks the item for enrichment.
- After tagging and embedding, the text row is deleted. What remains is the embedding and the tags.
- If tagging fails, the text is kept for review and removed by retention after 30 days.
- The text is never returned by the API.

## LLM calls

Every LLM call passes a data-class gate: `editorial`, `aggregate` or `user_derived`. Today only editorial text is sent (published catalog content, the taxonomy, flow templates). For `user_derived` data, prompts and answers are kept as hashes only, and OpenRouter is not cleared for that class. Calls go through EU endpoints with zero retention and pinned model versions. Audit rows of LLM calls live 30 days.

## Retention

Each data class has a retention period, editable in Governance: events, impressions, interactions and the audit log. The jobs apply it daily at 03:00 UTC. Fixed periods: LLM audit rows 30 days, LLM cache 180 days unused, texts of failed items 30 days.

Raw source payloads: none are stored, by the engine or the jobs. Only the allowed fields of each event are kept.

## Deletion

Send a deletion for the random id:

```curl
curl -sS -X DELETE "https://patterna-api.orven.am/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe" \
  -H "Authorization: Bearer $PATTERNA_INGEST_KEY"
```

```json
{
  "id": "6f1c2b9e-3d4a-4f0e-9a51-2c7d8e1b0a44",
  "user_id": "u_mfrggzdfmztwq2lknnwg23tpobyxe",
  "requested_at": "2026-10-13T09:12:00Z",
  "rows_deleted_at": "2026-10-13T09:12:00Z",
  "models_retrained_at": null,
  "stored_as": "sha256"
}
```

1. **Rows, immediately.** In one transaction before the answer, the engine deletes the user row, holdout assignments, opt-out marker, events and precomputed shortlist, and unlinks the user's impressions (user id removed, candidate list emptied). `rows_deleted_at` is set in the answer. The slates keep only a salted `unit_hash`, so the ledger does not change after the fact.
2. **Models, overnight.** The nightly learning run retrains on data without the user and stamps `models_retrained_at`. That is how deletion reaches model artifacts.

The deletion record stores a salted hash of the id, not the id. Governance shows both timestamps for every request. Switching a user to `non_profiled` is an opt-out: the request deletes the user row, keeps only a salted hash of the id as the opt-out marker and files a deletion request holding only that hash; the hourly job then unlinks the user's impressions (interactions stay with their slate) and deletes the user's events, assignments and shortlist. The call takes the ingest key. Step by step: [Handle a deletion request](https://www.patterna.nl/docs/handle-a-deletion-request).

## Export

Export everything Patterna holds about one user (admin key):

```curl
curl -sS "https://patterna-api.orven.am/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"
```

You hold the mapping from your account to the random id, so you answer the access request; Patterna supplies the rows.

## Isolation between tenants

Every tenant table uses row-level security. Each connection sets the tenant before it touches data, and a cross-tenant test runs in CI. The tenant always comes from the API key, never from the request body.

## Read next

- [Consent modes](https://www.patterna.nl/docs/consent-modes)
- [Identity across connectors](https://www.patterna.nl/docs/identity-across-connectors)
- [Handle a deletion request](https://www.patterna.nl/docs/handle-a-deletion-request)
