GDPR / CCPA Compliance
This guide helps EDDI operators handle data subject requests and meet GDPR/CCPA requirements. For an overview of data processing, see PRIVACY.md.
Handling Data Subject Requests
1. Right to Erasure (GDPR Art. 17 / CCPA §1798.105)
When you receive an erasure request:
# Via REST API
curl -X DELETE https://your-eddi-instance/admin/gdpr/{userId} \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"The response carries a count for every store the cascade touches, plus whether the cascade actually finished:
{
"userId": "user-123",
"memoriesDeleted": 15,
"conversationsDeleted": 8,
"conversationMappingsDeleted": 3,
"logsPseudonymized": 42,
"auditEntriesPseudonymized": 156,
"attachmentsDeleted": 4,
"journalEntriesDeleted": 2,
"checkpointsDeleted": 9,
"groupConversationsDeleted": 1,
"sharedArtifactsDeleted": 0,
"schedulesDeleted": 0,
"connectionGrantsDeleted": 1,
"failedSteps": [],
"complete": true,
"completedAt": "2026-04-02T15:30:00Z"
}Check
completebefore filing the request as fulfilled. The cascade deliberately continues past a failing store, so the categories after it are still erased — which is exactly whyconversationsDeleted: 0on its own cannot be read as "this user had no conversations". When any step throws, the endpoint answers 207 Multi-Status,completeisfalse, andfailedStepsnames the steps that did not run. Some of the user's data is still there: re-run the erasure and do not report it to the data subject as done. A cascade in which every step succeeded answers 200 with an emptyfailedSteps.The step names are
auditRewrite,inFlightConversations,runningGroupDiscussions,userMemories,restrictionCache,conversationIdLookup,attachments,hitlToolJournal,conversationDescriptors,conversationCheckpoints,conversations,conversationMappingIntents,conversationMappings,conversationMappingCache,groupConversations,sharedArtifacts,schedules,oauthStates,connectionGrants,userMemoriesResweep,databaseLogsandauditLedger, and they name the stores in the list below plus the two cache evictions, the two lookups the cascade needs to reach them, and the two signals that stop in-flight work first.conversationIdLookupis the worst one to see: the id resolution the per-conversation sweeps depend on failed, so those sweeps ran over nothing and their zero counts mean "not attempted", not "nothing to do".
Via MCP: Use the delete_user_data tool with confirmation="CONFIRM". It reports the same outcome: status is "completed" only when every step succeeded and "partially_completed" otherwise, alongside the same complete and failedSteps.
__service__ (the owner of service-bound connection grants) is a reserved system principal, not a user: erasing or exporting it is refused with 400.
What happens: 0. The user's in-flight work on the node serving the request is stopped first: running conversation turns are cancelled (they then skip their write-back to user memory and their snapshot is discarded) and running group discussions are cancelled immediately. Without this, a turn or discussion still running wrote the data back seconds after the erasure reported success. Work running on another replica is not reachable from here; it is stopped by the stores refusing to recreate a deleted conversation or group discussion, and step 12 removes any memory it managed to write in the meantime. From this point the node's audit ledger also writes the user's pseudonym instead of their id for an hour (the keyed pseudonym v5 rows carry, whenever the ledger signs), so audit entries that cancelled work still flushes while it unwinds — or that were already queued — do not land raw after step 14.
User memories — permanently deleted
Binary attachments of the user's conversations — permanently deleted
HITL tool execution journal entries — permanently deleted
Conversation descriptors — permanently deleted
Conversation memory checkpoints — permanently deleted
Conversation snapshots — permanently deleted
Managed conversation mappings — permanently deleted (and evicted from their cache)
Group conversation transcripts — permanently deleted
Shared artifacts owned by the user — permanently deleted
Schedules owned by the user — permanently deleted
Pending OAuth authorization flows the user started are invalidated first (a callback completing afterwards would otherwise mint a new grant), then the OAuth connection grants (linked accounts) of the user, in every tenant — permanently deleted. Each holds a live refresh token for the user's account at the provider; the provider-side consent is not revoked by EDDI and may be revoked by the user there
User memories — re-swept, for writes that landed while the cascade ran
Database logs — userId pseudonymized (SHA-256 hash)
Audit ledger — userId pseudonymized (a keyed HMAC in v5 rows, SHA-256 hash in older ones — see audit-ledger.md)
2. Right of Access (GDPR Art. 15) / Data Portability (Art. 20) / Right to Know (CCPA §1798.100)
The export includes all user data in a structured, machine-readable JSON format:
All persistent user memories
All conversation transcripts (with full chat history)
All managed conversation mappings (intent→conversation bindings)
All audit processing records (capped at 10,000 entries)
Attachment metadata (conversation, storage reference, file name, MIME type, size) — the binaries themselves are not inlined and must be fetched via the attachment download API
Linked OAuth accounts (
connectionGrants: tenant, connection, status, scopes and dates) — never token material
Check
completebefore handing the bundle to the data subject. The endpoint answers 200 only when the bundle covers everything EDDI holds on the user, and 207 Multi-Status otherwise. Three things can make it incomplete, and each is named in the payload:
omittedCategories— personal-data categories this exporter does not reach. Today this list is never empty: group conversation transcripts, shared artifacts, schedules and HITL journal entries are erased by the Art. 17 cascade as this user's personal data but are not yet exportable. So the export endpoint currently answers 207 on every request andcompleteis alwaysfalse. It becomes 200 when those four exporters land.
conversationsTruncated— the per-request conversation cap (1,000) bit, because each snapshot is a full document load assembled in memory on the request thread.totalConversationssays how many the user actually has, and the omitted ones remain retrievable individually through the conversation API.
failedConversationIds— conversations the exporter could not load at all, listed by id, and therefore absent fromconversations. Retry the export or fetch those ids individually.A bundle with
complete: falseis not a complete Art. 15 / Art. 20 response — do not file the request as fulfilled on it. (The audit-record cap of 10,000 is still reported in the server log only.)207 rather than 206 Partial Content, which earlier releases sent: 206 is a range status and RFC 9110 requires a
Content-Rangealongside it, which this endpoint neither reads nor produces. Any client still branching on 206 needs updating.
Via MCP: Use the export_user_data tool. It reports the same fields in the payload — complete, omittedCategories, conversationsTruncated, totalConversations and failedConversationIds — and an agent acting on its answer must not report an incomplete bundle as fulfilled.
3. Right to Restriction of Processing (GDPR Art. 18 / LGPD Art. 18)
When a user disputes data accuracy or objects to processing, you can freeze their processing without deleting data:
What happens when restricted:
New conversation creation is blocked → returns 403 Forbidden
Message processing (
say) is blocked → returns 403 ForbiddenExisting data is preserved (not deleted)
Restriction status is stored as a user memory entry under the reserved key
_gdpr_processing_restricted(categorygdpr). Keys starting with_gdpr_are reserved for this bookkeeping: agents (rememberFact/forgetFact), property setters, the user-memory and properties REST endpoints and the MCP memory tools all refuse to write or delete them, the "delete all memories" surfaces keep them, and retention and Dream maintenance never touch them. Only this admin endpoint sets or lifts a restriction, and lifting removes every row under the keyAll restriction/unrestriction events are logged in the audit ledger
Caching the restriction flag — eddi.gdpr.restriction-cache-ttl-seconds (default 0, i.e. no caching)
The flag is read at conversation start and again on every say/sayStreaming, so it sits on the hottest path in the system. By default it is nonetheless read from the store every time: no verdict is cached, a restriction applied on any replica takes effect on every replica at once, and a store outage always answers 503 (ProcessingRestrictionUnavailableException) rather than being absorbed by a cached verdict. The cost of that default is one indexed lookup per turn.
Setting the property above 0 switches on a node-local cache with that TTL. restrict/unrestrict publish through it, so the node serving the admin call applies the change on the very next turn — but there is no cross-node invalidation, so any other node keeps answering "not restricted" from its own cache until the TTL expires, and for the same reason keeps answering from cache during a store outage instead of failing closed. A cached negative verdict is a suspended Art. 18 legal control, which is why it is not the default.
Multi-replica without conversation affinity — keep the default (
0). This is the only safe setting there until cluster-wide invalidation exists.Single node, or a cluster with conversation affinity — every turn of a conversation and every admin call reach the same node, which is what makes the explicit invalidation sufficient. Setting e.g.
eddi.gdpr.restriction-cache-ttl-seconds=30there buys back the per-turn lookup. Treat it as an explicit topology assertion, not a tuning knob: it is wrong the moment a second replica is added.
Use cases:
User disputes accuracy of stored data (Art. 18(1)(a))
Processing is unlawful but user requests restriction instead of erasure (Art. 18(1)(b))
User objects to processing pending verification (Art. 21)
4. Response Timeline
GDPR
30 days
Up to 90 days for complex requests
CCPA
45 days
Up to 90 days with notification
EDDI's erasure and export operations complete in seconds — the timeline constraint is your internal DSAR process, not the technical execution.
Retention Configuration
Deleted conversations follow the same clock. Deleting a conversation without deletePermanently=true (the Manager's default) ends it, hides it from every listing and denies access to anyone but its owner and admins. Its memory stays until deleteEndedConversationsOnceOlderThanDays has passed since its last interaction, and is then removed with the other ended conversations. With the default of 365 days, a conversation deleted this way can stay stored for up to a year. Use deletePermanently=true or a GDPR erasure when it has to go at once. (Earlier releases purged such a conversation at the next daily sweep if it had already ended, and never if it was still open.)
Ending idle conversations does not restart the retention clock. Both the daily idle sweep and the admin bulk end (POST /conversationstore/conversations/end-inactive, see Upgrading from EDDI 5) set the conversation to ENDED without touching its descriptor, so deleteEndedConversationsOnceOlderThanDays keeps counting from the last interaction.
Per-category retention allows different retention periods for:
Conversations — 365 days (default)
User memories — disabled by default (configure per-deployment)
The audit ledger has no retention property — by design. IAuditStore is an append-only contract that deliberately exposes no update or delete operation, so EDDI never time-expires audit entries. Erasure requests are satisfied by pseudonymizing the userId (IAuditStore.pseudonymizeByUserId), which the cascading-erasure path invokes — not by deleting entries. See Audit Ledger Legal Basis below for the legal basis. If your jurisdiction requires time-limited audit retention, implement it at the operational or database layer (archival job, partition drop, storage-level TTL); EDDI provides no application-level audit purge.
Operator note: earlier releases shipped an
eddi.audit.retentionDaysproperty inapplication.properties. No code ever read it, so setting it was a silent no-op — no audit entry was ever deleted by it. The property has been removed; if your deployment setseddi.audit.retentionDays(orEDDI_AUDIT_RETENTIONDAYS), drop it and use database-level archival instead.
The audit dead-letter sink holds personal data, and erasure does not reach it
When the ledger cannot persist an entry it writes that entry to the dead-letter sink — NATS JetStream when a connection is available, otherwise the JSONL file at eddi.audit.dead-letter-path (default /opt/eddi/data/eddi-audit-deadletter.jsonl). The record is the whole audit entry: the userId, the verbatim prompt and response, the LLM detail and the tool calls, plus the HMAC and agent signature. It has to be, or a dropped entry is neither replayable nor usable as evidence of what the ledger itself lost.
The consequence for Art. 17 is that the sink is a second location holding personal data that the erasure cascade does not touch. DELETE /admin/gdpr/{userId} pseudonymizes the ledger and the database logs; nothing rewrites the JSONL file or the JetStream subject. Secret redaction has already been applied to the entry (see Secret Redaction), but user content has not, because preserving the entry is the whole point of the sink.
As the controller you must therefore:
A non-empty dead-letter sink is an incident, not a steady state — see Incident Response.
Data minimization (Art. 5(1)(e)): Review the default retention periods and reduce them to the minimum necessary for your use case.
Audit Ledger Legal Basis
The EDDI audit ledger is retained indefinitely under two legal bases:
GDPR Art. 17(3)(e) — Compliance with a legal obligation
EU AI Act Articles 17/19 — Immutable decision traceability for AI systems
Upon erasure requests, userId fields in audit entries are pseudonymized (replaced with a SHA-256 hash). The audit data structure, timestamps, and decision records remain intact for regulatory compliance.
Controller Checklist
As the data controller, you must:
LLM Provider Data Flow
EDDI sends conversation content to the LLM provider configured per agent. You choose the provider via the Manager UI or configuration files.
Ollama
Your infrastructure
✅ Yes
jlama
Your infrastructure
✅ Yes
Anthropic
US/EU (varies)
❌ No
OpenAI
US
❌ No
Google Gemini
US/EU (varies)
❌ No
Mistral
EU (France)
❌ No
Azure OpenAI
Your Azure region
Partially
AWS Bedrock
Your AWS region
Partially
Oracle GenAI
Your OCI region
Partially
Hugging Face
Varies by model host
Partially
For maximum data sovereignty, use Ollama or jlama with self-hosted models.
What is sent to LLM providers:
The current user message
Recent conversation history (windowed)
Agent system prompt
What is NOT sent:
User IDs or account metadata
Data from other conversations or agents
API keys (except the provider's own authentication key)
CCPA-Specific Requirements
Do Not Sell (§1798.120)
EDDI does not sell personal information and has no mechanism to do so. Document this in your CCPA privacy notice.
Right to Know (§1798.100)
Use the export endpoint (GET /admin/gdpr/{userId}/export) to fulfill "right to know" requests. The response includes all categories of personal information collected.
Right to Delete (§1798.105)
Use the erasure endpoint (DELETE /admin/gdpr/{userId}) to fulfill "right to delete" requests. The cascade covers all data stores.
International Privacy Regulations
EDDI's GDPR/CCPA infrastructure covers the technical requirements of all major international privacy frameworks. Each regulation has a detailed feature mapping and deployer checklist in PRIVACY.md:
PIPEDA — Canada (10 Fair Information Principles)
LGPD — Brazil (Art. 18 data subject rights)
APPI — Japan (2022 amendments, EU adequacy)
POPIA — South Africa (8 processing conditions)
PDPA — Singapore, Thailand & Malaysia
PIPL — China (data localization, cross-border transfer)
The same erasure and export endpoints (DELETE /admin/gdpr/{userId}, GET /admin/gdpr/{userId}/export) work for all jurisdictions. EDDI provides the technical layer; deployer checklists cover organizational measures (consent, officer appointments, breach notification).
See Also
PRIVACY.md — Data processing overview and international regulations
hipaa-compliance.md — HIPAA deployment guide
eu-ai-act-compliance.md — EU AI Act compliance
compliance-data-flow.md — Data flow diagram for auditors
incident-response.md — Breach response runbook
templates/baa-template.md — Business Associate Agreement template
Last updated
Was this helpful?