Skip to main content

Documentation

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.

KeyRequiredPurpose
endpointyesInternal route CP calls. Must match ^/v1/{app_code}/internal/.
timeout_secondsno (60)Caller-side deadline, 1–600.
method, formatnoAccepted 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).

KeyRequiredPurpose
endpointyesInternal route CP calls. Must match ^/v1/{app_code}/internal/.
methodno (POST)POST or DELETE.
modeyescascade, anonymize, or hard_delete — see below.
idempotentno (true)When true, CP may safely retry the call with the same request_id.
asyncno (false)When true, respond 202 immediately and CP polls status_endpoint.
status_endpointwhen asyncInternal poll route. Must contain the {request_id} placeholder.
timeout_secondsno (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.

app.manifest.yamlyaml
# 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
app.manifest.yamlyaml
# 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: 1800

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.

KeyRequiredPurpose
codeyesStable identifier, ^[a-z][a-z0-9_]*$, ≤64 chars.
nameyesLabel shown in the consent UI.
descriptionnoHelper text.
requiredyesWhen true, the consent is accepted at sign-up and members cannot withdraw it from their settings. Reserve for legal-basis consents (terms, privacy).
defaultno (false)Initial state for new users. Marketing and analytics MUST default to false — GDPR consent is opt-in.
app.manifest.yamlyaml
# 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: false

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:

http
GET /v1/control-plane/internal/orgs/{org_guid}/users/{user_guid}/consents

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

http
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.

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:

http
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.

KeyPurpose
data_classificationpublic, internal, confidential, or restricted.
retention_daysPer-category retention (see below).
data_subjectsWhose data you hold: customer, prospect, employee, operator, anonymous_visitor.
data_usesProcessing purposes, free-form (e.g. support, essential.service).
datasetThe Fides-style, field-level data map (see below).
residencyData-residency behaviour the placement resolver reads (see below).
pii_fieldsDeprecated — a flat list of dotted PII column paths, superseded by dataset.

retention_days has three keys, each an integer number of days:

KeyPurpose
user_piiHow long user PII is kept.
audit_logsHow long audit rows are kept.
soft_deleted_recordsHow 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:

KeyRequiredPurpose
pathyesColumn / field path within the collection (matches your model column).
categoryyesStandard data category, e.g. user.contact.email, user.name, user.unique_id, user.content.
free_textno (false)Marks unstructured free text that may contain arbitrary PII.
localization_requiredno (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.

KeyDefaultPurpose
events_pii_policyguid_onlyguid_only = events carry only GUIDs, never PII in the payload. allow_pii = PII permitted.
notifications_send_fromrecipient_regionrecipient_region sends mail from the recipient's region; platform_default from the platform default.
access_cross_regionnoneCross-region read policy: none, brokered, or regional_only.
access_broker—Named broker used when access_cross_region is brokered.
app.manifest.yamlyaml
# 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_requests

Where 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.