You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The GDPR Art. 17 (Right to Erasure) backend is fully implemented on feat/data-protection-retention (services/platform/convex/governance/erasure.ts), but org admins cannot self-serve from the dashboard — the only way to file or inspect a request today is through the Convex dashboard, which in production is reachable only by deployment operators. This turns every data-subject erasure into a runbook ticket and breaks the GDPR Art. 12(3) one-month response posture.
This issue ships the admin UI explicitly listed as out-of-scope follow-up at the top of erasure.ts:
Out of scope (separate work-streams): … Receipt UI (admin "view erasure history" panel).
processErasureRequest internal action: cascades threads, propagates to RAG, runs nine per-table subject-scope erasers, scrubs audit-log PII while preserving the chain.
gdprErasureRequests row is the receipt: status: 'pending'|'running'|'done'|'partial'|'failed', slaDeadlineAt (+30d), per-category counters, errorMessage, requestedBy/At, completedAt.
Every state transition writes an audit log row (gdpr_erasure_requested / executed / denied / blocked_by_hold / cascade_attempts_exhausted).
Why this matters for production posture
GDPR Art. 12(3) gives the controller one month to respond, extendable by two further months for complex requests — and the extension itself must be communicated to the subject within the original month, with reasons (gdpr-info.eu/art-12, Securiti DSR Fulfillment Timeline). Today the row carries a fixed slaDeadlineAt = +30d and no extension model. Without an in-product extension flow the deadline is non-actionable.
Art. 19 requires notifying any other controllers that received the data; for Tale today that fanout is RAG-only and already handled, but the receipt needs to surface the count so an auditor can see Art. 19 was honored.
Art. 17 enumerates six lawful grounds (consent withdrawal, data no longer necessary, unlawful processing, legal obligation, child, objection). The current schema accepts a free-text reason only. Production DSR tooling (OneTrust, TrustArc, Ketch) all carry a structured reason code alongside the narrative, because regulators expect requests to be classified by ground for reporting (OneTrust DSR Automation, TrustArc Individual Rights Manager).
Naming: "Data Subject Requests", not "Erasure"
Every enterprise DSR tool uses the umbrella term — Art. 16 (rectification) and Art. 20 (portability) sit next to Art. 17 in those products, even when only erasure is implemented. Future-proofing the route, sidebar entry, and i18n namespace as data-subject-requests (with a kind: 'erasure' discriminator) avoids a breaking rename when the next request type lands. This issue still ships erasure only; the page is just named for the umbrella.
Identity verification: out-of-band by design
OneTrust's reference flow is Intake → Identity verification → Assignment → Find data → Communicate response. Tale is admin-mediated (B2B self-host) — the org admin who files the request is the IDV checkpoint, having already verified the subject's identity through their own organizational process (HR offboarding, ticketed support flow, etc.). The product does not add an in-flow IDV step in v1; the docs make this contract explicit so an external auditor sees the boundary.
i18n via useT('governance'); admin gate via useAbility().cannot('write', 'orgSettings').
User stories
US-1 — Admin files an erasure request
As an org admin I want to select a member, pick a GDPR ground, write a reason, and confirm with a typed phrase So that I can fulfill an Art. 17 request without leaving the dashboard.
No deleted PII content is rendered — only aggregate counts and identifiers. Receipt also shows the linked audit-log timeline (one row per state transition).
US-4 — Admin retries a partial request
As an org admin I want to re-run the processor on a partial request So that I can complete a request that was blocked mid-flight by a since-released legal hold.
Acceptance:
"Retry" button on partial rows only.
New mutation retryErasureProcessor(requestId) re-schedules processErasureRequest; idempotent against done/failed (returns the existing terminal state).
Audit log entry gdpr_erasure_retry_requested.
US-5 — Admin grants an Art. 12(3) extension
As an org admin I want to extend the SLA deadline by up to 60 additional days with a recorded reason So that the receipt reflects a lawful extension instead of an overdue breach.
Acceptance:
"Extend deadline" action on non-terminal rows only, before original slaDeadlineAt passes.
Can only be granted once per request (Art. 12(3) cap is a single 60-day extension).
US-6 — Legal-hold block surfaces actionably
As an org admin who tried to erase a held subject I want the block error to tell me which hold is in the way So that I can route to it and decide whether to release.
Acceptance:
LEGAL_HOLD_BLOCKS_ERASURE rejection rendered as a non-toast inline panel with the offending heldThreadIds / heldDocumentIds / userCustodianHeld / orgHeld flags from the error payload.
Each held id is a deep-link to /dashboard/$id/settings/governance/legal-hold (target prefilter where feasible).
Backend changes (small, additive, no breaking migration)
New retryErasureProcessor(requestId) mutation, admin-gated, only valid on partial, writes gdpr_erasure_retry_requested audit row, re-schedules processErasureRequest.
New query getErasureRequest(requestId) returning row + linked audit-log entries (filter on resourceType: 'user' + resourceId: targetUserId + action prefix gdpr_erasure_).
Acceptance criteria
Functional
Sidebar entry "Data subject requests" appears for org admins under /dashboard/$id/settings/governance/; gated for non-admins via useAbility.
Route /dashboard/$id/settings/governance/data-subject-requests lands on a list view; deep-link /.../data-subject-requests/$requestId opens the detail drawer.
All six user stories pass their acceptance.
Backend additions land in the same PR; existing rows missing reasonCode render as — without crashing.
Receipt never displays erased PII content (only aggregate counts and identifiers).
Compliance
Receipt surfaces ragDocumentsRemoved and the upstream-controller fanout count (Art. 19).
Reason code is required for new requests; the seven enum values map to GDPR Art. 17(1)(a)–(f) plus the operational contract_termination ground.
SLA badge derives from extensionDeadlineAt ?? slaDeadlineAt; "extension granted on {date} by {admin}: {reason}" line shown when applicable.
All admin actions (file, retry, extend) emit a dedicated audit-log entry; the detail drawer renders the chain.
Out-of-band identity-verification contract is documented in the admin docs page.
Component: form validation, typed-confirm gate, SLA badge color thresholds.
Out of scope (explicit boundary)
BetterAuth user / account / session / verification table erasure (separate work-stream — owned by the auth component).
Subject self-service portal — Tale is admin-mediated by design.
Art. 16 (rectification) and Art. 20 (portability) UIs — the page is named for the DSR umbrella so they can be added as additional kind values without a rename.
In-product identity verification flow — handled out-of-band by the admin's organization.
Subject email notification on completion — Tale has no shared email-template infrastructure today; defer to the email-infra workstream.
Problem / Goal
The GDPR Art. 17 (Right to Erasure) backend is fully implemented on
feat/data-protection-retention(services/platform/convex/governance/erasure.ts), but org admins cannot self-serve from the dashboard — the only way to file or inspect a request today is through the Convex dashboard, which in production is reachable only by deployment operators. This turns every data-subject erasure into a runbook ticket and breaks the GDPR Art. 12(3) one-month response posture.This issue ships the admin UI explicitly listed as out-of-scope follow-up at the top of
erasure.ts:Context
What the backend already gives us
requestErasuremutation:(organizationId, userId, reason)→{requestId, threadsTargeted}. Refuses withLEGAL_HOLD_BLOCKS_ERASURE/ALREADY_PENDING/forbidden/validation.processErasureRequestinternal action: cascades threads, propagates to RAG, runs nine per-table subject-scope erasers, scrubs audit-log PII while preserving the chain.gdprErasureRequestsrow is the receipt:status: 'pending'|'running'|'done'|'partial'|'failed',slaDeadlineAt(+30d), per-category counters,errorMessage,requestedBy/At,completedAt.gdpr_erasure_requested/executed/denied/blocked_by_hold/cascade_attempts_exhausted).Why this matters for production posture
GDPR Art. 12(3) gives the controller one month to respond, extendable by two further months for complex requests — and the extension itself must be communicated to the subject within the original month, with reasons (gdpr-info.eu/art-12, Securiti DSR Fulfillment Timeline). Today the row carries a fixed
slaDeadlineAt = +30dand no extension model. Without an in-product extension flow the deadline is non-actionable.Art. 19 requires notifying any other controllers that received the data; for Tale today that fanout is RAG-only and already handled, but the receipt needs to surface the count so an auditor can see Art. 19 was honored.
Art. 17 enumerates six lawful grounds (consent withdrawal, data no longer necessary, unlawful processing, legal obligation, child, objection). The current schema accepts a free-text
reasononly. Production DSR tooling (OneTrust, TrustArc, Ketch) all carry a structured reason code alongside the narrative, because regulators expect requests to be classified by ground for reporting (OneTrust DSR Automation, TrustArc Individual Rights Manager).Naming: "Data Subject Requests", not "Erasure"
Every enterprise DSR tool uses the umbrella term — Art. 16 (rectification) and Art. 20 (portability) sit next to Art. 17 in those products, even when only erasure is implemented. Future-proofing the route, sidebar entry, and i18n namespace as
data-subject-requests(with akind: 'erasure'discriminator) avoids a breaking rename when the next request type lands. This issue still ships erasure only; the page is just named for the umbrella.Identity verification: out-of-band by design
OneTrust's reference flow is Intake → Identity verification → Assignment → Find data → Communicate response. Tale is admin-mediated (B2B self-host) — the org admin who files the request is the IDV checkpoint, having already verified the subject's identity through their own organizational process (HR offboarding, ticketed support flow, etc.). The product does not add an in-flow IDV step in v1; the docs make this contract explicit so an external auditor sees the boundary.
UI patterns to mirror
GOVERNANCE_GROUPSinservices/platform/app/routes/dashboard/$id/settings/governance/route.tsxwith the new entry, placed adjacent tolegal-hold(legal hold blocks erasure, so the proximity is semantically right).legal-hold/active-holds-section.tsx—PageSection+DataTable+ dialog components.ConfirmDialog'srequireConfirmPhrase(matches the legal-hold place-hold UX from commit 42de588).trash-page.tsx.useT('governance'); admin gate viauseAbility().cannot('write', 'orgSettings').User stories
US-1 — Admin files an erasure request
As an org admin
I want to select a member, pick a GDPR ground, write a reason, and confirm with a typed phrase
So that I can fulfill an Art. 17 request without leaving the dashboard.
Acceptance:
reasonCode(enum dropdown, required:consent_withdrawn/no_longer_necessary/unlawful_processing/legal_obligation/objection/child/contract_termination),reason(free-text, required, ≥ 10 chars), typed-confirm input (literalERASE).requestErasure; success routes to the request detail; failure surfaces inline (legal-hold block, already-pending, forbidden).US-2 — Admin sees the request list
As an org admin
I want a paginated, filterable list of past and in-flight requests
So that I can prove ongoing compliance and spot stuck requests.
Acceptance:
US-3 — Admin opens the receipt
As an org admin
I want the full erasure receipt for a request
So that I can hand it to a regulator or to the subject.
Acceptance:
errorMessageif failed.US-4 — Admin retries a partial request
As an org admin
I want to re-run the processor on a
partialrequestSo that I can complete a request that was blocked mid-flight by a since-released legal hold.
Acceptance:
partialrows only.retryErasureProcessor(requestId)re-schedulesprocessErasureRequest; idempotent againstdone/failed(returns the existing terminal state).gdpr_erasure_retry_requested.US-5 — Admin grants an Art. 12(3) extension
As an org admin
I want to extend the SLA deadline by up to 60 additional days with a recorded reason
So that the receipt reflects a lawful extension instead of an overdue breach.
Acceptance:
slaDeadlineAtpasses.extensionReason(free-text, required).extendErasureDeadline; persistsextensionGrantedAt/extensionGrantedBy/extensionReason/extensionDeadlineAt. Audit log entrygdpr_erasure_extended.US-6 — Legal-hold block surfaces actionably
As an org admin who tried to erase a held subject
I want the block error to tell me which hold is in the way
So that I can route to it and decide whether to release.
Acceptance:
LEGAL_HOLD_BLOCKS_ERASURErejection rendered as a non-toast inline panel with the offendingheldThreadIds/heldDocumentIds/userCustodianHeld/orgHeldflags from the error payload./dashboard/$id/settings/governance/legal-hold(target prefilter where feasible).Backend changes (small, additive, no breaking migration)
gdprErasureRequestsschema additions (all optional, default-undefined):reasonCode: v.union(v.literal('consent_withdrawn'), v.literal('no_longer_necessary'), v.literal('unlawful_processing'), v.literal('legal_obligation'), v.literal('objection'), v.literal('child'), v.literal('contract_termination'))extensionGrantedAt: v.optional(v.number())extensionGrantedBy: v.optional(v.string())extensionReason: v.optional(v.string())extensionDeadlineAt: v.optional(v.number())requestErasureto acceptreasonCode(required for new rows; existing rows tolerate absence on read).extendErasureDeadline(requestId, extraDays, extensionReason)mutation, admin-gated, single-grant cap, writesgdpr_erasure_extendedaudit row.retryErasureProcessor(requestId)mutation, admin-gated, only valid onpartial, writesgdpr_erasure_retry_requestedaudit row, re-schedulesprocessErasureRequest.listErasureRequests(organizationId, { status?, cursor? })returning rows + nextCursor.getErasureRequest(requestId)returning row + linked audit-log entries (filter onresourceType: 'user'+resourceId: targetUserId+ action prefixgdpr_erasure_).Acceptance criteria
Functional
/dashboard/$id/settings/governance/; gated for non-admins viauseAbility./dashboard/$id/settings/governance/data-subject-requestslands on a list view; deep-link/.../data-subject-requests/$requestIdopens the detail drawer.reasonCoderender as—without crashing.Compliance
ragDocumentsRemovedand the upstream-controller fanout count (Art. 19).contract_terminationground.extensionDeadlineAt ?? slaDeadlineAt; "extension granted on {date} by {admin}: {reason}" line shown when applicable.Quality / housekeeping (per
AGENTS.md)bun run checkis green (format / lint / typecheck / tests).services/platform/messages/{en,de,fr}.jsonundergovernance.dataSubjectRequests.*; every key present in all three./docs/{en,de,fr}/platform/admin/data-subject-requests.md;bun run --filter @tale/docs lintandbun run --filter @tale/docs testgreen.README.md/README.de.md/README.fr.md— N/A unless feature lands in feature highlights.aria-liveon status changes, focus management on drawer open/close.Out of scope (explicit boundary)
user/account/session/verificationtable erasure (separate work-stream — owned by the auth component).kindvalues without a rename.References