Compliance & DSGVO
Four features that implement DSGVO Art. 15 (Auskunft), Art. 17
(Löschung mit Grace), Art. 18 (Restriction), and Art. 20 (Portabilität)
for any multi-tenant Kumiko app. Domain entities join the pipeline through
two hooks, export and delete. The framework provides the forget cron,
export ZIP, magic-link delivery, and auth middleware guard.
compliance-profiles
Section titled “compliance-profiles”Status: ✅ Stable
What: Region-specific defaults for grace periods, supervisory
authorities, notification languages, audit-log retention, sub-processor
contracts. Tenant-Admin picks one profile during onboarding; every
consumer feature reads compliance.forTenant(tenantId) and gets
profile-aware behaviour automatically.
Profiles shipped:
| Profile | Region | Authority | Languages | Tenant-Destroy-Grace |
|---|---|---|---|---|
eu-dsgvo | DACH (default) | BlnBDI Berlin | de, en | 30 days |
swiss-dsg (extends eu-dsgvo) | CH | EDÖB Bern | de, fr, it, en | 30 days |
de-hr-dsgvo-hgb (extends eu-dsgvo) | DE-HR | Landes-Datenschutzbehörde | de | 60 days (HR-Override) |
minimal-no-region | , | (migration edge-case) | , | , |
Per-tenant overrides via override.userRights.gracePeriod={ days: N }
keep the rest of the profile inherited.
Example:
import { createComplianceProfilesFeature } from "@cosmicdrift/kumiko-bundled-features/compliance-profiles";
await runDevApp({ features: [createComplianceProfilesFeature(), myFeature],});
// Tenant-Admin sets profile via the dispatcherr.queryHandler({ name: "settings", handler: async (_q, ctx) => { const profile = await ctx.dispatch.queryAs(ctx.user, "compliance-profiles:query:for-tenant", {}); return { gracePeriodDays: profile.userRights.gracePeriod.days }; },});data-retention
Section titled “data-retention”Status: ✅ Stable
What: Retention policy resolver per Entity in 3 layers (Entity-
Default → Tenant-Preset → Tenant-Override). Cleanup-Cron deletes or
anonymizes rows after keepFor expires; supports blockDelete for
legal-hold periods (HGB, Steuer-Aufbewahrungspflicht).
How it works: Add retention: { keepFor: "90d", strategy: "hardDelete", reference: "createdAt" }
to createEntity({...}). The resolver picks the most specific policy;
cleanup-runner iterates due rows in batches.
Tenant preset: When compliance-profiles is mounted, the tenant’s profile picks
the preset: eu-dsgvo uses dsgvo-basic, de-hr-dsgvo-hgb uses dsgvo-hgb,
swiss-dsg uses swiss-dsg, and minimal-no-region uses default, which triggers
no automatic action. A tenant that has not chosen a profile counts as
minimal-no-region. Without compliance-profiles, only entity defaults and tenant
overrides apply.
The retention cron, the user-data-rights forget and the
data-retention:query:policy-for query resolve the policy the same way: override,
then preset, then entity default. On a dsgvo-hgb tenant, forgetting a user
therefore keeps invoice, booking and contract rows under blockDelete and only
anonymizes them, and it anonymizes order rows instead of deleting them.
policy-for reports source: "preset" for these entities.
Strategies:
hardDelete, row goneanonymize, the per-fieldanonymizefunctions run, row stays (multi-user-refs and business data intact). Requiresanonymize: () => ...on the PII fields, a policy without any anonymize-annotated field is reported as skipped (missing_anonymize_fields), and the boot validator warns about it.blockDelete, legal hold: rows stay untouched whilekeepForruns (HR/Steuer-Aufbewahrungspflicht,de-hr-dsgvo-hgbsets 10y for invoices). A user-forget during the hold anonymizes instead of deleting. After the hold expires the anonymize functions run : the row is never deleted, only its person link goes.
Example:
const invoiceEntity = createEntity({ fields: { amount: createNumberField({ required: true }), // person link: overwritten after the 10y hold expires (and on // user-forget during the hold); the row itself is never deleted. customerId: createTextField({ personal: "ref" }), customerName: createTextField({ personal: { of: "customerId" }, find: "none", anonymize: () => "[ANONYMIZED]", }), }, retention: { keepFor: "10y", strategy: "blockDelete", reference: "createdAt" },});user-data-rights
Section titled “user-data-rights”Status: ✅ Stable
What: Full DSGVO pipeline, request-export, request-deletion + cancel, restrict-account + lift, my-audit-log, list-download-attempts (DPO operator-query). Plus the async export-job worker (ZIP streaming + storage upload + signed magic-link with SHA-256 hash + edge-rate-limit
- 90d brute-force-detection-audit).
How it works: Domain features hook into EXT_USER_DATA with two
hooks per entity:
defineFeature("notes", (r) => { r.requires("user-data-rights"); r.useExtension(EXT_USER_DATA, "note", { export: async (ctx) => ({ entity: "note", rows: await ctx.db.select().from(notesTable) .where(and(eq(notesTable.tenantId, ctx.tenantId), eq(notesTable.authorId, ctx.userId))), }), delete: async (ctx, strategy) => { const where = and(eq(notesTable.tenantId, ctx.tenantId), eq(notesTable.authorId, ctx.userId)); if (strategy === "anonymize") { await ctx.db.update(notesTable).set({ authorId: null }).where(where); } else { await ctx.db.delete(notesTable).where(where); } }, });});That’s it. For every tenant the user belongs to, the forget cron
resolves each hooked entity’s retention policy with
resolveRetentionPolicyForTenant (override, then preset, then entity
default). An anonymize or blockDelete policy calls the hook with
the anonymize strategy. Any other policy, or none, calls it with
delete. Afterwards the cron anonymizes the user row. Export-Job builds the ZIP, sends a Magic-Link.
Endpoints shipped:
| Article | Handler / Cron | Who |
|---|---|---|
| Art. 15 | user-data-rights:query:my-audit-log | User (account-wide event history) |
| Art. 15 + 20 | user-data-rights:write:request-export | User (async ZIP + magic-link) |
| Art. 15 + 20 | GET /user-export/by-token | anonymous (magic-link, multi-use within TTL) |
| Art. 15 + 20 | GET /user-export/by-job/:jobId | User (session-auth, cross-tenant-same-user) |
| Art. 17 | user-data-rights:write:request-deletion + cancel-deletion | User |
| Art. 17 | run-forget-cleanup | Cron (after grace) |
| Art. 18 | user-data-rights:write:restrict-account + lift-restriction | Admin / SystemAdmin |
| Operator | user-data-rights:query:list-download-attempts | Admin / DPO |

A user who can no longer sign in can still request erasure. The
request-deletion-by-email handler sends a confirmation link to the account’s
address, and confirm-deletion-by-token starts the deletion once the link is
opened. On the client, makePublicDeletionGate serves both steps on login-free
routes; the sample mounts the request screen at /account/delete.
user-data-rights-defaults
Section titled “user-data-rights-defaults”Status: ✅ Stable
What: Default EXT_USER_DATA hooks for every bundled entity that
holds per-user data:
user, anonymizes the row with a sentinel email (deleted-<id>@anonymized.invalid), replacesdisplayName, nullspasswordHash, blocks loginfileRef, deletes file rows + storage binaries ondelete, keeps row + nullsinsertedByIdonanonymizeuser-session(ip, userAgent), hard-deleteapi-token, hard-delete (which also revokes the token)in-app-message, hard-deletetenant-invitation, invitee rows forgotten/pseudonymized, inviter link severednotification-preference+ user-scopedconfig-value, purged via the executor forget verb
Hooks for optional source features (sessions, personal-access-tokens, channel-in-app, tenant, delivery, config) no-op when the feature isn’t mounted, mounting the defaults never forces you to mount the sources.
Why it’s optional: App-authors with custom user-anonymize policies (e.g. soft-delete with retention) can leave this feature out and register their own hooks. 95% of apps just mount it.
Example:
await runDevApp({ features: [ createComplianceProfilesFeature(), createDataRetentionFeature(), createUserDataRightsFeature(), createUserDataRightsDefaultsFeature(), // covers the bundled entities above notesFeature, // your domain ], auth: { jwtSecret: process.env["JWT_SECRET"]! },});See also
Section titled “See also”- Recipe:
samples/recipes/user-data-rights/, minimal Notes-domain withEXT_USER_DATA-Hooks, 3 tests - Sample-App:
samples/apps/user-data-rights-demo/, runnable dev-server with todos-domain - Compliance-Operator-Guide:
packages/bundled-features/src/user-data-rights/COMPLIANCE.md, Verarbeitungsverzeichnis material + AVV-TOM list + Datenschutzerklärung snippets - Cross-Data-Matrix Test:
packages/bundled-features/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts, pins the 3-provider integration (user + fileRef + custom-domain)
What this framework does NOT cover
Section titled “What this framework does NOT cover”App-author responsibility (legal, not technical):
- Selecting + AVV with concrete storage/email providers
- Data-breach reporting to supervisory authority
- DSFA (Datenschutz-Folgenabschätzung), the framework provides TOM inputs, the assessment is yours
- Cookie-consent layer (frontend / separate feature)
- Tenant-lifecycle destroy (Sprint 5, separate feature)
Known technical limitation:
- Event payloads written before crypto-shredding keep their plaintext.
Kumiko is event-sourced; forget rewrites the read side and appends
anonymized events. Fields annotated
pii/userOwned/tenantOwnedare encrypted under a per-subject key when a KMS adapter is configured (runProdApp({ kms })), forget erases that key, which makes the values unreadable in rows and events (crypto-shredding, see the concept page and the crypto-shredding sample). Plaintext payloads appended before the KMS was active keep their original bytes; the backfill for those and coverage for customctx.events.appendpayloads are tracked in kumiko-framework#799.