GDPR & compliance
Declare the GDPR export, erasure and consent endpoints the Control Plane calls, plus the data classification that drives compliant handling.
The Control Plane runs the cascade; your manifest declares your part
Data-subject rights — export and erasure — are platform-wide by definition: one person's data is smeared across many services, so no single app can answer "give me everything you hold about this user" or "delete it" on its own. The Control Plane owns that orchestration. When a request comes in it fans out to every app, keyed by user_guid, calling the endpoints each app declares in a gdpr block. Your job is to declare those endpoints and to classify what data you hold. CP does the rest.
Two blocks carry this, both under spec:
gdpr— the internal endpoints CP invokes for export and erasure, plus the consent types this app owns.compliance— how your data is classified, how long you keep it, and a field-level data map the residency resolver reads.
Apps that handle user PII MUST declare gdpr, or CP refuses to provision them. Declaring nothing is not a way to opt out — it is a publish-time failure.Every endpoint below MUST sit on the internal auth tier — /v1/{app_code}/internal/..., x-api-key-gated — the same tier CP uses for all service-to-service calls (see Auth & the five gates). The schema enforces the path prefix; a non-internal endpoint is rejected at publish.
gdpr.export — hand your data back
CP calls your export endpoint with an HTTP GET, once per target: one for the app-wide placement (orgs still on the shared database) and one per organization storage binding in the serving region. The query string carries user_guid and, for an organization target, org_guid; an organization target also sends that org in the X-Org-Guid header. The request goes to the internal tier with Control Plane's registered caller key. Answer 200 with a JSON object, optionally wrapped in the standard BaseResponse envelope. CP aggregates every target's answer into one unified export (Rule 12.2).
An empty export is a 200 with an empty object. A 404 is not read as "no data": CP records it as a typed failure (not_found) for that target, the same way it records unavailable (429 or 5xx, or no connection), refused (401 or 403), bad_response (any other status, or a body that is not a JSON object) and not_configured. The result lists every target and which ones answered, so an export that could not reach a service is never mistaken for a user who has no data there.
| Key | Required | Purpose |
|---|---|---|
endpoint | yes | Internal route CP calls. Must match ^/v1/{app_code}/internal/. |
timeout_seconds | no (60) | Caller-side deadline, 1–600. |
method, format | no | Accepted by the manifest schema but not read by the export orchestrator: CP always uses GET, and always expects one JSON object. format: ndjson streaming is not supported by the orchestrator; do not rely on it for large datasets (return a bounded summary or a download reference in the object). |
gdpr.erasure — delete, anonymize, or scrub
CP POSTs {user_guid, request_id, mode} to your erasure endpoint. A synchronous handler returns 200 with {records_affected}; an async one returns 202 and CP then polls your status endpoint (Rule 12.1).
| Key | Required | Purpose |
|---|---|---|
endpoint | yes | Internal route CP calls. Must match ^/v1/{app_code}/internal/. |
method | no (POST) | POST or DELETE. |
mode | yes | cascade, anonymize, or hard_delete — see below. |
idempotent | no (true) | When true, CP may safely retry the call with the same request_id. |
async | no (false) | When true, respond 202 immediately and CP polls status_endpoint. |
status_endpoint | when async | Internal poll route. Must contain the {request_id} placeholder. |
timeout_seconds | no (300) | Caller-side deadline, 1–3600. |
The mode you pick reflects what your data is allowed to become:
cascade— soft-delete the user's rows and chain the erasure to dependent records. The default for ordinary tenant data.anonymize— scrub the PII but keep the row for referential integrity.
The audit trail is the exception to all three: its rows hold identifiers only, never names or emails. Erasure deletes the events about the person and appends one erasure record, so the hash chain still verifies.
hard_delete— irreversible row removal. Only for apps with no audit or compliance constraint on the data.
Long-running deletes (millions of EAV rows, a full audit archive) MUST run async: respond 202, do the work in the background, and expose a status endpoint whose path carries {request_id} so CP can poll to completion.
# AuthN — small, synchronous, cascade delete of credential + session rows.
gdpr:
export:
endpoint: /v1/authn/internal/gdpr/export
erasure:
endpoint: /v1/authn/internal/gdpr/erase
mode: cascade# Audit — a longer export deadline, and an async ANONYMIZE that keeps the hash chain.
gdpr:
export:
endpoint: /v1/audit/internal/gdpr/export
timeout_seconds: 300
erasure:
endpoint: /v1/audit/internal/gdpr/erase
mode: anonymize
async: true
status_endpoint: /v1/audit/internal/gdpr/erase/{request_id}
timeout_seconds: 1800gdpr.consent — the consent types you own
consent.types[] lists the consents this app asks people for. Control Plane reads the types from every live manifest and shows them together on each member's Privacy choices page in the customer portal, so an app without consents (AuthZ, Audit) simply omits the block. At least one type is required when the block is present.
Every decision is kept in a consent log in the member's organization database: one entry per grant or withdrawal, with the document version where there is one, where it happened (sign-up, settings, the portal's one-time question, an unsubscribe link) and when. The newest entry for a type is the current choice, and the earlier entries are the proof GDPR Art. 7(1) asks for. Members can turn an optional consent on or off at any time; required ones are accepted at sign-up and cannot be withdrawn from settings.
| Key | Required | Purpose |
|---|---|---|
code | yes | Stable identifier, ^[a-z][a-z0-9_]*$, ≤64 chars. |
name | yes | Label shown in the consent UI. |
description | no | Helper text. |
required | yes | When true, the consent is accepted at sign-up and members cannot withdraw it from their settings. Reserve for legal-basis consents (terms, privacy). |
default | no (false) | Initial state for new users. Marketing and analytics MUST default to false — GDPR consent is opt-in. |
# Control Plane — two legal-basis consents (required) and two opt-in ones.
gdpr:
consent:
types:
- code: terms_of_service
name: Terms of Service
description: Required to use the platform.
required: true
- code: marketing_email
name: Marketing communications
description: Product news, tips, and offers.
required: false
default: false
- code: product_analytics
name: Product analytics
description: Anonymous usage telemetry to improve the product.
required: false
default: falseReading and recording a member's consent from your service
Before acting on an optional consent (for example, sending marketing email), read the member's current choice from Control Plane. Call it with your service key, naming the member's organization from your verified request context:
GET /v1/control-plane/internal/orgs/{org_guid}/users/{user_guid}/consentsEach entry carries app_code, code, required, granted, document_version, recorded_at and source. A type that was never answered reads granted: false with no recorded_at, so treat it as no consent.
When your service learns of a decision the member made outside the portal, record it:
POST /v1/control-plane/internal/orgs/{org_guid}/users/{user_guid}/consents/{app_code}/{code}
{"granted": false, "source": "unsubscribe_link"}source is one of signup, invitation, terms_update, unsubscribe_link or complaint. A member's own choices come only from the portal, so settings is refused here. An unknown organization, member or consent type answers 404.
Visitors without an account: the cookie banner log
A visitor's cookie choice is kept as proof too, with nothing that names them. A site's cookie banner posts each choice to Control Plane, which stores it in the region the request reaches:
POST /v1/control-plane/public/consents/anonymous
{"consent_id": "<random uuid the browser made>", "consent_type": "product_analytics",
"choice": "granted", "banner_version": "1"}The call needs no sign-in and answers 204. The browser keeps consent_id and sends the same one when the visitor changes their mind, so the log shows the change. The row holds the id, the type, the choice, the banner version and the time. It holds no IP address, user agent or account. The banner treats Accept and Decline alike and keeps working if the call fails.
A daily job removes anonymous rows after the consent-proof retention period (legal.data_retention.consent_proof.retention_days, three years by default). A member's log is kept while the account exists; after the account closes, or after a withdrawal, it is kept for the same period and then deleted. The consent log is part of a member's personal-data export.
compliance — classification, retention, and the data map
The compliance block describes your data at rest: how sensitive it is, how long it lives, and — field by field — what each column actually is.
| Key | Purpose |
|---|---|
data_classification | public, internal, confidential, or restricted. |
retention_days | Per-category retention (see below). |
data_subjects | Whose data you hold: customer, prospect, employee, operator, anonymous_visitor. |
data_uses | Processing purposes, free-form (e.g. support, essential.service). |
dataset | The Fides-style, field-level data map (see below). |
residency | Data-residency behaviour the placement resolver reads (see below). |
pii_fields | Deprecated — a flat list of dotted PII column paths, superseded by dataset. |
retention_days has three keys, each an integer number of days:
| Key | Purpose |
|---|---|
user_pii | How long user PII is kept. |
audit_logs | How long audit rows are kept. |
soft_deleted_records | How long before soft-deleted rows are auto-purged. |
dataset — the field-level classification the resolver reads
dataset is the modern replacement for pii_fields. Instead of a bare list of column names, you declare each collection (a table or logical grouping) and, within it, each field with a standard data category. This is the Fides-style classification the residency placement resolver reads to decide where each field may live — the app says what the data is; the platform policy decides where it goes.
Each entry in dataset[] has a required collection name and a list of fields. Each field is:
| Key | Required | Purpose |
|---|---|---|
path | yes | Column / field path within the collection (matches your model column). |
category | yes | Standard data category, e.g. user.contact.email, user.name, user.unique_id, user.content. |
free_text | no (false) | Marks unstructured free text that may contain arbitrary PII. |
localization_required | no (false) | The field must be stored in the data subject's own region. |
residency — regional placement inputs
Choosing the region where customer data is stored is not yet available on the platform. The residency sub-block is the contract an app declares in advance for how its data may cross regional boundaries; declaring it does not place data in a region by itself. For the placement model behind these knobs, see Tenancy & deployment.
| Key | Default | Purpose |
|---|---|---|
events_pii_policy | guid_only | guid_only = events carry only GUIDs, never PII in the payload. allow_pii = PII permitted. |
notifications_send_from | recipient_region | recipient_region sends mail from the recipient's region; platform_default from the platform default. |
access_cross_region | none | Cross-region read policy: none, brokered, or regional_only. |
access_broker | — | Named broker used when access_cross_region is brokered. |
# Support — full data map: classification, subjects, uses, per-field dataset,
# and residency. free_text marks the columns a user can type anything into.
compliance:
data_classification: confidential
retention_days:
user_pii: 1095
audit_logs: 2555
soft_deleted_records: 90
data_subjects:
- customer
- anonymous_visitor
data_uses:
- support
- essential.service
dataset:
- collection: ticket
fields:
- { path: requester_email, category: user.contact.email }
- { path: requester_name, category: user.name }
- { path: requester_guid, category: user.unique_id }
- { path: subject, category: user.content, free_text: true }
- { path: description, category: user.content, free_text: true }
- collection: support_comment
fields:
- { path: author_guid, category: user.unique_id }
- { path: body, category: user.content, free_text: true }
residency:
events_pii_policy: guid_only
notifications_send_from: recipient_region
access_cross_region: brokered
access_broker: support_access_requestsWhere this sits in the platform
The gdpr endpoints are called by CP, not by users — they belong on the internal tier alongside your lifecycle hooks, and CP reaches them the same way it reaches everything else service-to-service. The compliance.dataset is read once, at the placement decision point, by the residency resolver — never re-derive PII-ness anywhere else. Declare both honestly: the export and erasure endpoints are what make a real deletion request actually delete, and the classification is what keeps a field in the region it is legally required to stay in.