Data we never store
PII rules, allowlists per event, and how deletion and export reach every row and every model.
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.
How it is enforced#
| Layer | Rule |
|---|---|
| SDKs | Refuse email-shaped and `provider |
| 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 |
| 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 -sS -X DELETE "https://api.patterna.nl/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe" \
-H "Authorization: Bearer $PATTERNA_INGEST_KEY"{
"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"
}- 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_atis set in the answer. The slates keep only a saltedunit_hash, so the ledger does not change after the fact. - 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.
Export#
Export everything Patterna holds about one user (admin key):
curl -sS "https://api.patterna.nl/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.