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’suser_id in the workspace identity store, then:
data_subject_id (the three data tables below plus gdpr_actions, the audit history — see “What’s in the export” for the boundary):
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 adata_subject_id foreign key, plus the subject’s own read markers and the audit history itself:
peer_cards— agent-authored peer cards mentioning the subjectmemory_versions— versioned memory blobs the agent wrote about the subjectinbox_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 onuser_idrather thandata_subject_id. The items themselves may be anyone’s — a role-targeted escalation the subject happened to open — so each entry carries the item’stitleandkindbeside its id, and the list is scoped to this workspace’s itemsuser_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 requestuser_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 themgdpr_actions— the audit history itself (always included)
What’s NOT in the export
Content the operator may also need to surface manually:lessons.mdentries that mention the subject byuser_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 anylessons.mdbut does not modify the file. Operators are responsible for redacting if the lesson text contains personal data. See Memory tiers.- Chat conversation history — the
chatstable is workspace-scoped not subject-scoped; if the subject’s user_id isuser_idon 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 byactor_user_idrather thandata_subject_idbecause 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.
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
…_anonymisedkey and is not counted inrows_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 thegdpr_actionsrow 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_bycannot 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 aREATTRIBUTEDevent naming the custodian and the action id, and the count is reported ascredentials_reattributed. If nobody can take custody, the whole #2308 step refuses and the response is207with 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.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:
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_actionsrow, 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_idreturned 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
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: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).
Audit table reference
gdpr_actions schema (added in migration v107):
Indexes:
idx_gdpr_actions_subject (workspace_id, data_subject_id)— fast SAR lookupidx_gdpr_actions_initiated (initiated_at)— chronological browse
Querying the audit
Every Article 15 + Article 17 attempt for a subject: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.- 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
- 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
- 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
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_actionsrow 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
- Threat model — Tenant isolation on internal-auth handlers — how the GDPR cascade endpoints fit into Crewship’s tenant-isolation surface area
- Audit logging — broader audit log shape;
gdpr_actionsis a specialized table for the GDPR cascade subset - Encryption at rest — operator-side encryption that protects
gdpr_actionsrows in backups + filesystem snapshots - Agent memory — the operator model — how peer cards get created and what
data_subject_idthey end up with - Admin API — schemas for the two endpoints