Data-subject-rights runbook — access, export, erasure¶
Who this is for: whoever operates a DataQ deployment and receives a GDPR Art 15 (access) / Art 17 (erasure) / Art 20 (portability) or CCPA/CPRA know-or-delete request that touches data DataQ has captured. DataQ is customer-deployed (BYOL, ADR 0013) — the deploying organization is the controller and owns the response to the data subject; this runbook is the processor-side half: the mechanism DataQ provides and how to drive it.
0. What DataQ can and can't do here¶
DataQ has no people-table. The personal data it can hold incidentally lives in
three places: results.sample_failures and results.observed_value — small
evidence samples of failing rows, copied out of the controller's own warehouse
tables — and incidents.evidence, a stored snapshot of an incident's evidence
card whose failing_result.observed_value can carry the same kind of literal
warehouse cell value (see the DPIA input sheet for the
full inventory). This runbook's access/erasure endpoints below cover all three
(the incident snapshot is matched by the check's current tested column — see §5). A "data subject" in DataQ's own
data is identified the same way the warehouse identifies them: a (column,
value) pair — e.g. column=email, value=alice@example.com — not a DataQ user
id (that is Class 2, workspace-account data, and is handled by ordinary account
deletion/export, not this runbook).
This runbook does not touch the controller's warehouse. The warehouse tables DataQ reads remain the controller's system of record and the controller's responsibility to act on separately; DataQ's own erasure/export only covers what it has itself captured and stored.
1. Who can do this¶
Both endpoints below are Admin-only (ADR 0033) — the same tier as a connection credential, because an unredacted export of regulated data is exactly that sensitive. There is no MCP tool for either capability, for the same reason MCP carries no connection-credential tool at all.
1a. Driving it from the app¶
Both capabilities have a UI: Admin → Compliance → Data-subject rights. Enter the identifier column and the value, then Export data or Erase subject. This is the normal path — the API below is the same machinery, for scripting a bulk response or for a deployment where the operator works from a shell.
The UI adds three things the raw endpoints do not:
- Erasure is gated on retyping the value exactly. The endpoint has no confirmation argument, and a mis-pasted value erases a different subject's data.
- The receipt is rendered as well as downloadable, so it is still readable where the browser blocks a script-initiated download. Keep it with the request record.
- A partial erasure is shown as a warning, not as success, with the per-store counts broken out.
The audit events both actions write are visible in the audit log on the same page.
2. Access / export (GDPR Art 15 / 20)¶
POST /api/v1/admin/data-subject-requests/export
{"column": "email", "value": "alice@example.com"}
Scans every suite in the workspace and returns every result whose captured
sample_failures or observed_value names that column/value pair — unredacted,
by design: this endpoint IS the subject's own access right, and the read-path
redaction ladder exists to protect other people's data from an unrelated viewer,
not this one. Each match carries the suite/check/run it came from and which JSONB
column(s) matched (matched_in), so the response is also a ready-made
"categories and volume of records" input for a notification or a portability
export.
Records one audit_events access event (data_subject_request.export,
exposed: <bool>) — the same accountability trail as any other regulated-data
read in DataQ.
3. Erasure (GDPR Art 17 / CCPA delete)¶
POST /api/v1/admin/data-subject-requests/erase
{"column": "email", "value": "alice@example.com"}
Runs the same workspace-wide match, then surgically removes only the matching row/cell:
- from
sample_failures, only the failing-row entries wherecolumn == valueare dropped from whichever list-bearing key held them (unexpected_index_list,partial_unexpected_list, or a comparison bucket) — every other row, and every other key in the same blob (counts, summaries), is untouched; - from
observed_value'sunparsed_valueshape, only the value is nulled — thecolumnname it was captured against survives, since that is metadata about the check, not the subject; - from
observed_value's list-shaped case (a set-oriented expectation's full distinct-value list), only the matching entry is removed from the list.
This is deliberately not the retention sweep's granularity (which nulls a whole
sample_failures/observed_value column once its age crosses
a clock). A GDPR erasure right does not license destroying data belonging to
unrelated rows or unrelated subjects, and an operator debugging why a check
failed still needs whatever of the sample isn't the erased subject's. If a
result's entire sample genuinely was one subject's data, the natural retention
clock (or deleting the covering suite/check) is the tool for erasing the rest.
Runs synchronously and returns {matched_count, erased_count} as totals over
results and incident evidence snapshots, with a per-store breakdown
(matched_result_count / erased_result_count / matched_incident_count /
erased_incident_count) so an erasure confirmation can say where the data was.
matched_* is how many rows contained the subject's data, erased_* how many were
actually modified (normally equal; they can diverge only if a match's shape has no
scrub path, which does not exist today but is reported honestly rather than
assumed). Records one audit_events config event
(data_subject_request.erase, carrying both counts) inside the same transaction
as the scrub, so a failed write leaves nothing behind and an applied one cannot go
unrecorded — the same pattern as every other audited admin mutation (ADR 0041).
4. Exercising it (demo/test verification)¶
To exercise erasure on a demo user before relying on it for a real request:
POST /api/v1/admin/data-subject-requests/exportwith a column/value you know is in a demo suite's failing samples; confirm it comes back inmatches.POST .../erasewith the same pair; confirmerased_count >= 1.- Re-run the export — the subject's value should no longer appear, while
GET /api/v1/runs/{run_id}still shows the result and its other data. If the failing check had opened an incident,incident_matchesin step 1 lists it anderased_incident_countin step 2 counts it;GET /api/v1/incidents/{id}should show the evidence card with that one value gone and everything else intact. - Check
GET /api/v1/admin/audit-events?action=data_subject_request.erasefor the recorded event.
5. Limits, stated rather than left to be discovered¶
- Matching is a full scan of the JSONB sample columns, not an indexed lookup — there is no way to index an arbitrary column-name/value match inside a schemaless blob. This is an operator-triggered, low-frequency action, not a hot path, and is expected to take longer on a workspace with a large results table.
- Only the shapes the redaction ladder already knows about are covered:
unexpected_index_list/partial_unexpected_listrows, the three comparison buckets (mismatched,additional_in_source,additional_in_target), and bothobserved_valuePII-bearing shapes. A future sample shape needs its own match/ scrub branch, mirroring how the retention sweep's own shapes are enumerated. - Value matching is string equality (
str(cell) == value). A numeric or differently-formatted cell that is semantically the same value but not the same string representation will not match — state that in the response to the subject if a match seems to be missing. incidents.evidenceis matched by the tested column as of the snapshot's write time, not the check's current one. Both endpoints scan the incident evidence snapshot (see §0) as well asresults. An incident keeps noResultrow, so the column is resolved from the check's version history as of the incident'slast_seen_at— the moment the evidence was last (re)written — the same rule theresultsscan applies per row. A check whosecolumnwas edited after the incident last fired therefore no longer hides its snapshot from the match. The one residual: a check with no version history (created beforecheck_versionsexisted and never edited since) resolves to its current column, which for such a check is also the only column it has ever had.- This does not touch the controller's warehouse — see §0.
Last reviewed: 2026-09-02.