Skip to main content

GDPR — Article 15 access + Article 17 cascade

Crewship stores agent-authored content (peer cards, memory snapshots, inbox items) that may include personal data about end users. EU operators have an obligation under GDPR Article 15 (Right of Access) and Article 17 (Right to be Forgotten) to produce that data on request and to delete it on demand. This page is the operator playbook for both flows. It covers the two admin endpoints, the audit table that records every action, the idempotency contract, and the parts of the cascade that intentionally remain manual for the operator’s judgement.

Two endpoints, one audit table

The gdpr_actions audit table is the canonical record of every Article 15 / 17 attempt — successful or failed — keyed by (workspace_id, data_subject_id) for fast SAR query lookup. Each invocation creates a new row even when repeated against the same subject, so the audit trail captures the full history of how the workspace handled that subject.

Article 15 — exporting a subject’s data

A data subject contacts the operator (typically via support email or a privacy portal) requesting a copy of all personal data held about them. The operator finds the subject’s user_id in the workspace identity store, then:
The response is a single JSON bundle containing every row from the four tables that carry data_subject_id (the three data tables below plus gdpr_actions, the audit history — see “What’s in the export” for the boundary):
The handler returns 500 Internal Server Error on ANY query failure — even if a partial bundle was assembled from successful queries — to prevent an incomplete export from being handed to the subject as if it were authoritative. The audit row is still written with status='failed' so the attempt is recorded; the operator retries after investigating the underlying failure. The export is the canonical artefact the operator hands the subject. The format is JSON because it’s machine-readable for downstream redaction tooling; the operator is responsible for translating it into whatever the subject requested (PDF, CSV, etc.) if a specific format was named.

What’s in the export

The export covers tables that carry a data_subject_id foreign key, plus the subject’s own read markers and the audit history itself:
  • peer_cards — agent-authored peer cards mentioning the subject
  • memory_versions — versioned memory blobs the agent wrote about the subject
  • inbox_items — inbox rows whose payload references the subject (e.g. persona-suggestion proposals about the subject)
  • inbox_item_reads — the subject’s own per-item read markers (“I read inbox item X at time Y”), the one table here keyed on user_id rather than data_subject_id. The items themselves may be anyone’s — a role-targeted escalation the subject happened to open — so each entry carries the item’s title and kind beside its id, and the list is scoped to this workspace’s items
  • user_models — the operator model: the subject’s role, ownership and stated working constraints. Exported with its body, not just the index row — it is the one surface here holding facts the subject stated about themselves, so a row count would not answer the access request
  • user_model_provenance — the evidence behind each of those facts (#1693): the subject’s own quoted words, the message they were found in, and when each was recorded, newest first. Every row, not only the newest per field — the history of what was recorded is part of what is held about them
  • gdpr_actions — the audit history itself (always included)

What’s NOT in the export

Content the operator may also need to surface manually:
  • lessons.md entries that mention the subject by user_slug — agents append to a per-crew lessons file with free-form text; the cascade logs a warning naming the subject when its slug is found in any lessons.md but does not modify the file. Operators are responsible for redacting if the lesson text contains personal data. See Memory tiers.
  • Chat conversation history — the chats table is workspace-scoped not subject-scoped; if the subject’s user_id is user_id on a chat row, the operator filters those rows separately. Covered by Chat sessions.
  • Audit logs from internal/journal — operator-facing operations the subject performed are tracked in the journal; the operator queries by actor_user_id rather than data_subject_id because the journal is for what the operator did, not what was done to a subject.

Article 17 — cascade delete

A data subject requests deletion of all personal data (“right to be forgotten”). The operator runs:
reason is REQUIRED. The handler rejects an empty or whitespace-only reason with 400 Bad Request, and the underlying gdpr_actions CHECK constraint rejects the same at the DB layer (defense-in-depth — even a future admin SQL bypass that skips the handler can’t land an unjustified delete row). Whitespace-only reasons like " " or "\n" are also rejected because they’re functionally blank for audit purposes.

The contract: unnamed in this workspace

The erasure is workspace-scoped, and the promise it makes is precise:
Of the tables the cascade enumerates below, no row belonging to this workspace still names the subject once the cascade completes.
It does not delete the users row, and it does not touch another workspace’s rows. The same person in a second workspace is a second SAR ticket answered by a second call with that workspace’s id; page_grants a subject issued in workspace B survive an erasure run against workspace A, by design.
Read “of the tables below” literally — it is not yet “every table”. Four kinds of row deliberately keep naming the subject after a successful cascade, and only the first is an accountability record:
  1. journal_entries, audit_logs and gdpr_actions — permanent by design, listed in the table below.
  2. workspace_members — the subject stays a member of the workspace. Erasure and removal are separate operations (crewship workspace member remove); an erasure that also evicted would be doing something the operator did not ask for.
  3. chats and lessons.md — flagged as operator-handled in the table below, and still manual.
  4. Columns still awaiting a product decision, listed under “What stays excluded” below: an issue’s owner, the subject’s recorded peer-card consent and its audit trail, a memory proposal’s decider and an agent’s config-change history. The sweep in internal/api/admin_gdpr_pages_identity_test.go walks the live schema, so this list is what it reports, not what someone remembered to write down.
Everything else the sweep found — some forty columns across credentials, notification channels, routines, issues, backups and the subject’s own saved views and preferences — is reached since #2308, with one receipt key per table.
Two consequences an operator should expect on the receipt:
  • Anonymised is not deleted. Where a row is history worth keeping — a page version, say — the erasure clears the subject’s name from it and leaves the row. It is reported under an …_anonymised key and is not counted in rows_deleted, because nothing was deleted.
  • Revoked is deleted. Where a row is a live capability the subject issued — a grant, a public link, a webhook token, a standing routine approval, a workspace invitation — the erasure removes it. The capability stops working immediately, and the row is counted in rows_deleted. Its accountability trail is the gdpr_actions row this cascade writes, not the capability row itself.
  • Removed is the subject’s own. A saved view, a notification preference, a delivery addressed to them, their personal notification channel: rows that are not attribution on somebody else’s record but the subject’s own data. Deleted, counted in rows_deleted, reported under …_removed.
  • Re-attributed is the one exception. A credential the subject created keeps working for every agent bound to it — deleting it would take a secret the workspace still relies on — but its created_by cannot be blank. The admin running the erasure becomes its custodian (the oldest other OWNER if the admin is erasing themself), the credential’s own audit timeline gets a REATTRIBUTED event naming the custodian and the action id, and the count is reported as credentials_reattributed. If nobody can take custody, the whole #2308 step refuses and the response is 207 with the reason.

What the cascade touches

What stays excluded

The sweep still reports these after a successful cascade. Each is a decision not yet taken, not an oversight, and the test rig deliberately does not seed them so a green sweep cannot be mistaken for coverage:
For verified private synchronous whole-run sources, hard deletion also appends an atomic, content-free approval.source_retired journal marker (workspace and approval ID only). It retains no request, answer, user or chat/run metadata; its purpose is to keep existing immutable source history private after erasure. A failed marker append prevents deletion of that source.approvals_queue is erased on Article 17 but is not part of the Article 15 export bundle above — it lacks a data_subject_id column, so it doesn’t fit the export’s “every table keyed by data_subject_id” shape, and adding a requested-by/decided-by-keyed export is tracked separately rather than folded into this cascade. Its own retention sweep (internal/harbormaster/retention.go, workspaces.approvals_retention_days, default 90 days) bounds how long an undecided-to-erase row survives on its own — see Migrations — 20260901134904 — but a live SAR ticket cannot wait out that window, which is why the cascade above erases it directly rather than relying on the sweep.
No per-subject marker survives the erasure. Each peer card and operator model is written under a <user_slug>.md.lock sentinel, and that sentinel is now removed with the file it guards — on the Art. 17 cascade, on crewship privacy user-model delete, and on the opt-out purge alike. It used to stay: a zero-byte file named after the subject, left in a directory whose other artefacts were all gone. user_slug is sha256(user_id ‖ 0x00 ‖ workspace_id)[:16] and carries no personal data, but it is recomputable by anyone holding the workspace id and a list of users, so a listing still answered “did this workspace ever hold a record about this person?” after the record itself was erased. A purge for a subject who never had a card or a model now writes nothing at all, rather than creating the sentinel it was called to remove.
Known gap: a dormant crew’s search index is not purged. Every crew’s sidecar keeps an FTS5 index (index.sqlite) alongside .memory/, built by walking the memory tree — including users/{user_slug}.md — at container startup and on a 60-second tick while the sidecar is running. The Art. 17 and Art. 15 cascades and crewship privacy user-model delete all run host-side and only touch the .md file and its .lock sentinel; none of them open index.sqlite. For a crew whose container is running when the erasure happens, the next 60-second reindex tick naturally drops the erased content from the index — it walks the tree fresh and the file is gone. For a dormant crew (a container that is stopped — typically because the operator has since moved to a different crew, the exact condition this page’s per-crew-directory delete exists to reach), the erased content’s search chunks persist in that crew’s index.sqlite until its container is next started, and memory.search scoped to that crew would still surface them. Proposed fix: extend the host-side delete to also open each crew’s index.sqlite (when present) and remove rows for users/{user_slug}.md, matching what Engine.ReindexPath already does for a file that has disappeared from disk — gated on the index file already existing, so a crew with no engine yet never gets one created just to record an absence.
The response confirms what landed. rows_deleted is the single total of rows that went away; the per-table breakdown is in scope, and its keys carry the verb, because “anonymised” and “revoked” are different answers to give the person who filed the request:
(Abridged: the real object carries one key per table in the cascade, every one present even at zero.) pages_transferred, every …_anonymised key and credentials_reattributed are deliberately outside rows_deleted: nothing was deleted in any of those cases — a page changed owner, a version lost an author, a credential got a custodian. The …_removed / …_revoked counts are inside it. The same object is stored on the audit row as gdpr_actions.scope_json, so the breakdown survives the response. A key at zero is not the same as a key missing: the #1976 and #2308 steps each run in one transaction that rolls back whole on failure, and their keys are then reported at zero, so “the rollback undid it” and “there was nothing to do” read differently from a key that was never there.

Idempotency

Running the cascade twice for the same subject is safe:
  • Already-deleted rows are silently skipped (cascade is row-set based, not snapshot-based)
  • Each invocation creates a new gdpr_actions row, so the audit trail records BOTH attempts (operators sometimes re-run a cascade weeks after the first attempt to verify completeness — both runs land in the audit)
  • The action_id returned in the response is the audit row id, not a transaction id — quoting it back to the operator’s ticketing system gives a stable handle
This matters because regulators sometimes ask “did you delete this subject’s data on 2026-03-14 AND again on 2026-04-02 in response to two separate confirmation requests?” — the answer comes from SELECT * FROM gdpr_actions WHERE data_subject_id = ? AND action = 'delete' ORDER BY initiated_at and shows both runs without ambiguity.

Verifying a cascade landed

After running the cascade, the operator verifies completeness by re-running the export:
A successful cascade returns:
…with all scope counts at zero. If any non-zero remains, the cascade either had a partial failure (check the previous gdpr_actions row’s status and error columns) or there’s data without a data_subject_id foreign key that won’t be reached by the cascade (escalate to engineering — likely a column added since the v107 migration).
The export is not a full verification. It covers the tables keyed by data_subject_id, so a zero there says nothing about the tables that name the subject through some other column — approvals_queue, and the four Pages tables above, are erased by the cascade but never appear in the export bundle. For those, the scope on the gdpr_actions row is the evidence: it records what each statement actually removed or anonymised, per table, at the time it ran.

Audit table reference

gdpr_actions schema (added in migration v107): Indexes:
  • idx_gdpr_actions_subject (workspace_id, data_subject_id) — fast SAR lookup
  • idx_gdpr_actions_initiated (initiated_at) — chronological browse

Querying the audit

Every Article 15 + Article 17 attempt for a subject:
All cascade deletes performed by an admin in the last 90 days:
Failed exports the operator should retry:

UI surface

Both endpoints are reachable from Admin → Users: search for the person, open their row, and the two actions are there — Export data and Erase data. They used to live behind their own “GDPR actions” nav row, which carried its own user picker: a second roster of the same people the Users list already showed, kept in step by hand. The actions belong to a person, so they live where people live.
  1. The roster searches by name, email or user id; one row opens at a time, because one of these actions is irreversible and two open panels invite the wrong one being used
  2. Export data downloads the JSON snapshot; Erase data opens a confirmation dialog with a required reason field and an “I understand this is irreversible” checkbox
  3. The dialog stays open during the async DELETE (no auto-close) — a failed call surfaces the error toast without wiping the operator’s input, so they can retry without re-typing
From a terminal, the same two operations are crewship admin gdpr export and crewship admin gdpr delete --reason … --yes, which is the better surface for a queue of requests. The reason field is the same one that lands in gdpr_actions.reason — operators are encouraged to paste their ticketing system’s identifier (“SAR ticket #1234”) rather than free-form text, so the audit query reads naturally.

What’s NOT exposed

Crewship intentionally does NOT provide:
  • A self-service deletion endpoint for end users — Article 17 requests are mediated by the operator because the cascade affects content the operator owns (agent decisions, audit records that have to survive operator’s own retention obligations)
  • A bulk delete endpoint (“delete every user matching this filter”) — every cascade is one subject at a time, by design, because each gdpr_actions row needs a justification specific to that subject
  • Auto-deletion on user account close — closing a Crewship user account doesn’t trigger Article 17 (the user might have other obligations to a different workspace tenant within the same Crewship instance); the explicit cascade endpoint is the only path

Cross-references