Compliance is a first-class concern for a SaaS boilerplate. This page covers the built-in privacy mechanics for GDPR-style data-subject rights plus the launch checks needed for US, Canadian, and Swiss markets. This is an implementation baseline, not legal advice; review the final policy text and processor list with counsel before production launch.
Personal deletion from /my-account revalidates the current application identity at submission. The server atomically schedules the currently owned personal Account and workspaces together with the profile marker; B2B cascade applies only to owned workspaces. The configured calendar-day grace period is unchanged, and replay preserves the original deadline. Cancellation retains request history as cancelled and is refused if the flow is incomplete, ownership changed, or any request is processing or failed. Neither action suspends the user or signs them out during the grace period.
Personal exports require agreement between the revalidated Auth principal and its active application identity before any business-data read. Shared Prisma capabilities scope every section to the canonical actor and their exact Accounts, with an explicit owner check for personal financial data. The endpoint keeps its recent-authentication gate, returns an uncached CSV and exposes only synthetic failures. Consent state and history use the application user id; no fallback treats a provider subject as a business key. Final local-only certification remains separate from the implemented four-composition contract.
Authenticated privacy-preference changes require the application actor before parsing input. A shared actor-scoped Prisma transaction atomically records the three choices and their history; only an exact three-row receipt succeeds. complianceConfig.consentPreferenceTimeoutMs bounds the database operation to five seconds without an automatic replay. Signup terms, identity bootstrap and rejected OAuth cleanup use the same provider-neutral application identity.
The fresh consent schema references private.app_users directly for current state and append-only history. Its foreign keys are created validated with ON DELETE CASCADE; there is no equal-UUID bridge, historical backfill or native Auth trigger. Signup finalization creates or resolves the application identity explicitly before consent is written.
Profiles, Accounts, memberships and consent rows use the stable application user id. Provider, issuer and provider subject live in the identity mapping and are resolved explicitly during idempotent signup/OAuth finalization. A collision never merges identities implicitly, and rejected OAuth cleanup preserves tombstone semantics.
The page reads profile and memberships through an actor-scoped Prisma snapshot. Membership pagination fails closed on overflow or inconsistent data. workspaceConfig.dashboard.accountReadTimeoutMs supplies the shared deadline and cancellation; Auth presentation metadata remains behind the selected native adapter and never authorizes the business read.
Auth deletion uses core/auth/provider-user-erasure.ts with the selected Supabase Auth or Better Auth adapter. Provider, issuer and exact subject are checked before mutation. Lookup confirms the target; deletion is followed by provider-specific absence evidence before success. Already-absent targets are idempotent. The configured deadline consumes worker cancellation, and ambiguous outcomes remain checkpointed for read-only reconciliation rather than a blind second deletion.
Install the selected complete fresh composition before starting the worker. Initialization attests admission, fencing, finalization and durable queue capabilities. The compliance timeouts bound scheduling, cancellation, admission and finalization against the selected PostgreSQL database; provider requests have their own deadlines and occur outside database transactions. Destructive customer actions retain their configured step-up policy.
The installed schema must include database/schema/compliance/durable-account-deletion-context.sql before the updated worker. Each new request receives an immutable, database-derived deletion_context containing its Account ID and application user ID. It survives parent-reference removal before the first claim. Client-supplied processor proofs, terminal status and leases are discarded at admission; new requests start pending. The worker validates this context and its agreement with any surviving references before reading Account/profile data or contacting providers. The old target_account/requesting_user checkpoints are not trusted identity sources.
V2 is fresh-install only: it ships no upgrade backfill or legacy orphan repair. Never fabricate durable context, requeue a replacement request or reset provider checkpoints to force a retry; an external mutation may already have happened.
Before personal effects, admission revalidates the lease and ownership and waits while another owned Account, owner membership or non-terminal dependent request remains. Waiting returns the request to pending without consuming retries or changing scheduled_for; deletion_deferred_at keeps older eligible workspace work ahead even with a batch size of one. Cancelled/completed history does not block admission. Incoherent evidence becomes PERSONAL_ACCOUNT_ERASURE_BLOCKED for operator review.
Readiness writes a durable actor/Account fence. Database guards reject new ownership, target reattribution and new/reactivated dependent requests, including old concurrent transaction snapshots. Recovery recognizes the same fence after owner-reference release, profile deletion or identity tombstoning. Private control tombstones survive Account deletion; never clear them to force retries. Owner-reference release is scoped only to the admitted personal Account.
Every workspace-only request requires current ownership matching both the Account and an owner membership. A transferred or incoherent target fails as WORKSPACE_ACCOUNT_ERASURE_BLOCKED before effects. Readiness persists a private request/actor proof and prevents later transfer, owner release or target identifier reuse. Recovery uses this exact proof and a live lease even after memberships disappear. The owner's other Accounts are not frozen; workspace deletion does not remove their personal newsletter contact or Auth identity. This checks authority at admission, not uninterrupted ownership history: a transfer away and back before admission is not detectable without an ownership epoch.
Workspace requests with cascade_members=true fail before any effect with ACCOUNT_DELETION_MEMBER_COHORT_UNSUPPORTED, even if the workspace currently appears empty. Each member needs their own multi-Account erasure protocol. Do not silently flip the flag to bypass this refusal; B2B personal requests can consequently remain deferred until the dependent flow is resolved. Workspace-only deletion with false preserves member identities and Account-scoped notifications. The ownership fence does not stop every concurrent business producer; full terminal erasure and alternate database/Auth combinations remain uncertified. Guest requests fail before effects with ACCOUNT_DELETION_ACCOUNT_TYPE_UNSUPPORTED; empty guest cleanup is separate.
Membership INSERT/UPDATE is frozen for all roles on an admitted Account, or for an actor whose personal erasure has begun, even in another Account. Deferred guards check old and final coordinates, including chained identifier changes and UPDATE-then-DELETE; ordinary DELETE cleanup and transient native-signup INSERT-then-DELETE remain allowed. Refusal is transactional (55000 / ACCOUNT_ERASURE_MEMBERSHIP_BLOCKED); lock contention and serialization errors also abort the write. All membership writes now serialize per Account and actor with bounded waits. Do not retry fenced writes indefinitely. Direct acceptance, admin additions and role changes stop on failure. Invitation creation/resend emails, deliberately nonfatal B2B bootstrap and other business producers remain outside this membership-only barrier.
Invitation INSERT and ordinary UPDATE now immediately check both old/new Accounts and stored invited_by actors. Refusal is 55000 / ACCOUNT_ERASURE_INVITATION_BLOCKED. Only a pure status update to accepted/expired/revoked is exempt, with no other column change; DELETE remains legal. NULL attribution stays compatible with historical rows, but does not bypass the Account fence. The stored inviter is neither necessarily the current sender nor the recipient identity. Resend requires the same id/Account/pending/token tuple and one validated renewal result, so deletion, revocation, acceptance or token rotation winning before renewal prevents email delivery. An email can still be sent after erasure begins if its renewal committed before admission; database/provider delivery is not atomic. Other business producers and deliberately nonfatal B2B bootstrap remain separate work.
The final SQL deletion independently requires the exact durable context and private admission, even when called directly. Missing or contradictory proof quarantines the request with ACCOUNT_DELETION_FINALIZER_ADMISSION_REQUIRED at the retry ceiling; it retains the Account, grace date and provider checkpoints. Do not reset evidence or recreate a request to bypass operator review. Expiry while waiting for SQL locks or performing cascades rolls back the entire finalization. An exact already-completed request/Account/lease-digest replay remains a read-only acknowledgement, including historical requests without admission: it performs no new deletion. Local verification uses the disposable SQL suite and qa:local:erasure-concurrency, qa:local:workspace-erasure-concurrency, qa:local:erasure-finalizer-concurrency, qa:local:membership-erasure-concurrency, qa:local:invitation-erasure-concurrency via pnpm run; stop the local stack afterwards to remove fixture tombstones.
Document and chunk INSERT/UPDATE reject admitted Accounts or personally fenced stored authors. Chunk parents are locked and must belong to the same Account; documents with children cannot move Accounts. DELETE/cascades and exact author anonymization after profile deletion remain legal. Documents without authors remain readable and deletable but cannot be reprocessed or reassigned. Processing stops on fence refusal without another error-state write. A ready-state refusal can occur after token debit; already-started embeddings and RAG reads are not cancelled by this database boundary.
Document cleanup validates every Account/id/Storage-path batch before effects and deletes each metadata batch only after native Storage succeeds. It renews the lease before both effects and requires an explicit empty Prisma read before later cascades. Batch count, row count and deadline are bounded; exhaustion retains completed progress and fails the run for retry. Invalid configuration, malformed receipts or Storage failures prevent finalization.
Upload still precedes metadata INSERT. Compensation deletes only the generated path after a proven database rejection; ambiguous INSERT outcomes preserve the object. Cleanup failure is logged without raw provider payloads, but no durable orphan outbox exists. Storage/SQL atomicity, in-flight embedding cancellation, cross-Account member erasure and alternate-provider certification remain separate work.
API-key INSERT and ordinary UPDATE check both old/new Accounts against admission, including old transaction snapshots. Refusal is 55000 / ACCOUNT_ERASURE_API_KEY_BLOCKED; lock contention and serialization also abort. Only pure revocation to is_active=false may bypass the guard, with every other field unchanged. DELETE/cascades remain legal. Native foreign-key locks still apply: repeating revocation within one transaction can wait for admission's Account lock and reach the timeout. No permanent SQL privilege is added. Historical NULL-Account rows remain readable/revocable/deletable, not revivable or movable. Use pnpm run qa:local:api-key-erasure-concurrency on the disposable stack for concurrency verification.
Creation returns the plaintext key only after one active persisted metadata result matches the Account, sanitized name, prefix, scopes and expiration; portable creation also waits for transaction commit. The hash is never selected. Expiration accepts at most six fractional-second digits; higher precision is rejected before I/O. Listing and idempotent zero-row revocation are unchanged. Ambiguous results may leave a stored key without returning plaintext. API keys have no recorded canonical author, so this does not fence the current personal actor, automatically revoke existing credentials or implement API-key authentication. A creation committed before admission can publish its response afterwards; SQL and HTTP publication are not atomic.
Chat sessions, messages and AI usage INSERT/UPDATE check their old/new Accounts and their own stored authors against erasure admission. Historical NULL-author rows remain readable/deletable but cannot be mutated. Messages require a parent session in the same Account; parent coordinates are locked and a session with children cannot move Accounts. Sessions remain shared: an unfenced member can write their own message even when the session creator is personally fenced, provided the Account is open. DELETE/cascades and retention cleanup remain available. The guard owner can read only parent coordinates and child existence, never titles, message content or AI payloads.
Session creation/title changes reject malformed or mismatched stored metadata before success or cache revalidation. AI persistence failures remain synthetic best-effort logs, without retry or raw provider content. Usage, messages and the single actual-token debit still run independently in parallel: refused history persistence can coexist with a successful debit and completed stream. This does not block a new provider call, cancel an in-flight response, fence reads or make accounting atomic. Cross-Account contributions can still block profile deletion through existing foreign keys; no broad cross-Account purge or automatic anonymization is added. Run pnpm run qa:local:chat-erasure-concurrency on the disposable stack for the SQL boundary.
In-app notification INSERT/UPDATE checks the Account and stored recipient against erasure admission. Only an effective pure read=true update is exempt, including replay; existing column normalization and recipient/membership authorization stay intact. DELETE and Account/profile cascades remain legal. Account and recipient lock waits are bounded to one second, preserving parallel notifications for different Accounts sharing a recipient without automatic retry. No notification-content privilege is added to the guard owner.
Creation requires one minimal matching receipt before reporting success; mark-read keeps zero-row idempotence but requires an explicit successful database acknowledgement. Failures use synthetic logs. Push remains parallel and independent: a successful Push can establish the shared cooldown even when the bell write fails. This is not a cross-channel outbox, external-delivery fence or cross-Account erasure protocol.
Personal Push subscription INSERT/UPDATE now check both old/new stored recipients against personal erasure, without inventing Account scope. Refusal is 55000 / ACCOUNT_ERASURE_PUSH_SUBSCRIPTION_BLOCKED; native lock and serialization errors can precede it. Pure UPDATE to expired remains legal only when every other field except the generated updated_at is unchanged. This preserves the existing 404/410 cleanup, not reactivation, tracking or mixed key/recipient updates. DELETE and canonical application-identity cascades remain legal. Workspace-only erasure does not freeze personal devices, though its actor arbitration can invalidate an old repeatable-read snapshot.
Registration requires a matching active id/user_id/status receipt, and removal a confirmed array of UUID ids; an empty array remains idempotent success. No endpoint or encryption key is selected or logged by these CRUD helpers. Existing route authorization, validation and synthetic failure logs remain. An already-loaded subscription can still be sent after admission. Post-send tracking confirms actor-scoped Prisma receipts but remains best effort; durable delivery is a separate concern.
Personal notification preferences are created on the first settings change, not signup. Every INSERT/UPDATE now checks old/new stored actors against erasure, including no-op, all-channels-off and timestamp-only changes. Refusal is 55000 / ACCOUNT_ERASURE_NOTIFICATION_PREFERENCE_BLOCKED; native lock and serialization failures can precede it. DELETE/Auth cascades remain legal. No Account, new permanent grants or cleanup exception is added. Workspace-only admission leaves preferences open, although its physical actor arbitration can invalidate an old repeatable-read snapshot.
Saving preferences returns the ten settings from one validated matching UPSERT receipt. PATCH no longer performs a second read or fabricates defaults on missing readback. GET uses defaults only for confirmed absence; malformed settings or ambiguous database responses return a logged generic failure. SQL time precision, nullable historical settings, timezone identifiers and the existing differences between SQL defaults (threshold 100, null hours) and API absence defaults (threshold 10, 22:00/08:00) remain unchanged. Only settings are selected, not identity or audit fields. Run pnpm run qa:local:notification-preference-erasure-concurrency on the disposable stack.
This is a preference-write fence, not a delivery or read fence. Deleting preferences can restore reader defaults rather than suppress notifications. Already-loaded preferences, external delivery and alternate Auth/database certification remain separate work.
Delivery-log INSERT/UPDATE now check their optional Account and own stored recipient against erasure admission, including stale repeatable-read snapshots. Refusal is 55000 / ACCOUNT_ERASURE_NOTIFICATION_LOG_BLOCKED; native lock and serialization failures can precede it. Only a pure single-FK SET NULL after the referenced Account or subscription has actually disappeared is exempt. Both cascades work in either order; DELETE remains legal. No-op, clicked/delivered flags, timestamps, retargeting and fake cleanup remain fenced. The inert guard owner gains only subscription-id visibility, never endpoint, encryption-key, journal-content or Auth privileges.
The actual Push dispatcher journals through a validated six-field receipt: id, recipient, subscription, optional Account, type and delivered. A refused or ambiguous journal emits a synthetic incident and returns false, without changing an already-sent Push into a network failure, retrying delivery or inserting a second failure row. Delivered title/body/data remain unchanged. Newly stored provider errors and errors returned to callers use PUSH_DELIVERY_FAILED, with only a validated HTTP status optionally retained; historical errors are not rewritten. Click tracking requires an explicit successful lookup of at most one UUID and an explicit successful update, while retaining zero-row idempotence and best-effort HTTP 200.
This does not guarantee delivery, durable cooldown or an outbox, and does not fence reads or cancel external calls. Tags still address the latest recipient/type row, not an exact delivery. After genuine Account SET NULL, historical workspace attribution is lost: subsequent writes check the remaining recipient, not a retained workspace tombstone. No extra cross-Account purge is added. Run pnpm run qa:local:notification-log-erasure-concurrency on the disposable stack; no external Push service is contacted.
The dispatcher validates the canonical recipient, optional Account and notification type before database I/O. Its preferences come from the same strict Prisma reader as the settings API, with defaults only for confirmed absence. A malformed or ambiguous database receipt prevents delivery. An invalid timezone also prevents delivery when enabled quiet hours are evaluated.
Device preflight selects only id, recipient, endpoint, two encryption keys and active status. It verifies attribution, strict ordering, uniqueness and the bounded result count. Overflow or malformed rows refuse the whole attempt before any send. Endpoint/key values remain exact for the SDK but are absent from incident logs; device names, user agents and timestamps are not loaded.
config/push.ts owns databaseTimeoutMs=5000, maxActiveSubscriptions=100 and deviceConcurrency=10, with no new environment variable or migration. Each database read, post-send state update and delivery journal receives a real fetch/body cancellation signal and disables SDK retry. The deadline is per database I/O, not per dispatch or external Push call. Devices are sent in bounded chunks only after the complete snapshot is validated; concurrency is per dispatch, not a global job/user limit.
Usage tracking updates only the matching active device and confirms its id, recipient and requested timestamp. Provider 404/410 expiration updates only matching active/already-expired devices and confirms status, never reviving unsubscribed devices. Empty receipts remain idempotent no-ops. State and journal failures emit synthetic incidents outside the network catch: they cannot change sent/failed counts or cause another send. The SQL write fence still cannot prevent use of an already-read subscription.
The independent Web Push transport keeps SDK encryption/signing and now bounds each exchange to ten seconds, including DNS, TLS and the complete response body. Payload and response are bounded to 64 KiB by config/push.ts. Registration and egress share a strict HTTPS provider allowlist; every A/AAAA answer must be public, and the connection is pinned with TLS certificate verification intact. The low-credit job forwards parent cancellation to active network requests and stops later sends; its own candidate/preference/cooldown reads also consume that signal, while the Push dispatcher's independent database I/O and confirmed sends retain their contracts. Only a complete HTTP 404/410 response permits expiration. No retries, redirects or raw provider errors are introduced. Cancellation is not proof that a provider did not accept a message, a total job deadline, durable delivery or an erasure-to-send fence. See PWA & Push for configuration and DNS deployment details.
| Right | How it's served |
|---|---|
| Consent | Cookie banner with necessary/analytics/marketing categories; anonymous choices persist locally, while logged-in choices sync via POST /api/user/consents. Third-party analytics/chat scripts mount only after matching consent. |
| Demonstrable consent (Art. 7(1)) | user_consents holds the current decision per category; user_consent_events is an append-only trail of every grant and withdrawal. Both stamp policy_version from complianceConfig.privacyPolicyVersion. The actor-scoped application capability may change current consent, while the append-only evidence remains protected from direct subject writes. |
| Withdrawal | The footer exposes Cookie settings and Your Privacy Choices so users can reopen preferences, withdraw analytics/marketing consent, and reach privacy-rights actions after the banner is dismissed. |
| Access / portability (Art. 15/20) | GET /api/user/export-data streams a multi-section RFC 4180 CSV — 29 sections covering profile, personal accounts, memberships, chat/AI, consents and consent history, payments, subscriptions, licenses, invitations, access/admin logs tied to the user, deletion requests, notification preferences and delivery log, pending emails, document metadata, referral codes and referrals, affiliate applications/attributions/conversions, and API-key metadata without secrets/hashes. It is scoped to the caller's own data only. When you add a table holding personal data, add its section in the same change — and note that tables keyed on a domain id rather than an account id (e.g. affiliate_conversions.affiliate_id) need a second pass, since the account-scoped query alone only catches rows where the subject was the referred party. The Art. 17 erasure cascade is the checklist: any table it deletes is personal data the export must also cover. |
| Erasure (Art. 17) | Self-service deletion request enters a 30-day grace queue (account_deletion_requests). After database admission and before any local cascade, process-account-deletions re-reads and retires every subscription/customer belonging to the configured payment provider: Stripe proves deletion, while Lemon Squeezy/Paddle prove archival because the Merchant of Record retains financial evidence. It removes Account-scoped data and Storage objects (not just rows), then atomically deletes the Account and completes the request. Newsletter contact and Auth identity removal apply only to personal Accounts, not workspace-only deletion. |
| B2B owner cascade | Scheduling retains the configured cascade_members=true intent, but the worker refuses this unsupported path before effects. A member-cohort protocol is required; ordinary workspace-only deletion preserves member identities. |
| US state privacy | The footer links to /[locale]/privacy-choices, a dedicated "Your Privacy Choices" page for do-not-sell/share and targeted-advertising opt-out visibility. Cookie consent also honors browser Global Privacy Control signals by keeping marketing/sharing/targeted-ad consent disabled even when "Accept all" is clicked. |
| Canada | The default policy covers meaningful consent, reasonable purposes, safeguarding, consent withdrawal, breach-response procedures, and CASL-style commercial-message opt-in/unsubscribe expectations. |
| Switzerland | The default policy covers Swiss FADP/nFADP-style transparency, proportionality, purpose limitation, security, data-subject rights, international-transfer safeguards, and FDPIC high-risk breach notification expectations. |
| Marketing email | POST /api/newsletter/subscribe is Turnstile-protected and records server-side consent provenance attributes (CONSENT_SOURCE, CONSENT_TEXT, CONSENT_AT) with the configured email provider. |
Deletion requests are proof-bearing and leased: processor_status, processor_errors, error_message, retry_count, processing_lease_token, and processing_lease_expires_at preserve redacted, fenced outcomes for payments, newsletter/email, Storage, database cascade, and the selected Auth provider. Every external resource is checkpointed before mutation; a crash or ambiguous response becomes read-only reconciliation on retry, never a blind second write.
Customer-facing routes: /[locale]/privacy-choices, /[locale]/privacy, /[locale]/ai-transparency, /[locale]/terms, and /[locale]/legal. API routes: GET /api/user/export-data, POST /api/user/consents, POST /api/org/schedule-deletion, POST /api/org/cancel-deletion (all authenticated; deletion is owner-only at the API). Before launch, edit the terms / privacy / legal CMS pages and confirm your processor list: the selected database, Auth, Storage, payment, email, analytics and AI providers. Disclose whether the selected payment provider is the application's processor (Stripe) or Merchant of Record (Lemon Squeezy/Paddle).
If you ship the AI features, Article 50 of Regulation (EU) 2024/1689 binds you independently of GDPR. It has applied since 2 August 2026 and was excluded from the Digital Omnibus deferral that moved high-risk obligations to December 2027. The boilerplate implements the chat disclosure, machine-readable output marking, and the public transparency notice — but the risk classification and the provider DPAs are yours. See EU AI Act compliance.
Four operator deliverables that no boilerplate can generate for you ship as fill-in templates: records of processing (Art. 30), a DPIA (Art. 35, likely required because of the AI features), a 72-hour breach runbook (Art. 33/34), and the subprocessor register and DPA clauses (Art. 28) your B2B customers will ask for.
US/Canada/Switzerland launch checklist: publish real business contact details; verify commercial email templates identify the sender and include unsubscribe where the message is promotional; confirm whether your analytics/ads setup is sale, sharing, or targeted advertising and that /privacy-choices accurately describes it; document breach notification ownership; and configure the selected provider's CAD/CHF catalogue bindings if Canadian or Swiss checkout is enabled.
Also set LEGAL_COMPANY_NAME, LEGAL_ADDRESS, and LEGAL_REGISTRATION_NUMBER to real production values before enabling NEXT_PUBLIC_INDEXABLE=true. Example placeholders intentionally block public/indexable production builds.