Owner: the founder. Approved: 2026-09-05. Review: annually.
The published retention terms live in docs/legal/data-retention-policy.md
and are served at /legal/data-retention. They are a customer commitment. This
page is the internal layer: which mechanism discharges which promise, and where
the seams are.
A published retention term must be enforced by a mechanism, not asserted by a sentence. Publishing a term nobody honours is a storage-limitation failure under GDPR Art. 5(1)(e) and a transparency failure under Art. 13(2)(a) — and it is the first line an auditor or a customer's security questionnaire writes down.
Where the number and the mechanism could drift apart, they are bound in code:
AUDIT_LOG_RETENTION_MONTHS is one integer that the SQL interpolates and the
published policy quotes, and tests/test_audit_log_retention.py fails if the two
stop being the same number.
| Data class | Term | Discharged by |
|---|---|---|
| Customer Data (CRM, outreach, drafts, contracts) | subscription life + 30 days | src/ataski/offboarding.py (three-phase purge) |
| Contact PII, per tenant | same as the parent customer | same purge |
| Firmographic cache (non-PII, cross-tenant) | 30 days from last confirmation | src/ataski/firmographic_retention.py |
| Outreach send history | 24 months | src/ataski/outreach_retention.py |
| Audit log | 24 months, then scrub | src/ataski/audit_retention.py |
| Global opt-out register (hashed) | indefinite — legal obligation | never auto-purged |
| Right-to-delete request log | at least 7 years | retained as proof of erasure |
The audit log is append-only at the database grant level: the runtime role holds
SELECT and INSERT, and REVOKE UPDATE, DELETE is explicit. So the 24-month term
is discharged by nulling the personal columns and emptying the payload while
keeping the action / actor / resource / cost skeleton (ADR-0107). A retention
term is satisfied by ceasing to hold the personal data, not by destroying the
record; deleting rows would also punch holes an auditor cannot tell apart from
tampering, and would take the cost history the spend rollups depend on with it.
Scheduled with a 30-day undo → a committed write fence that refuses every
object-storage write and presigned URL for that tenant → a hard delete of every
tenant-scoped row, then the R2 objects, then a data_purged_at stamp as proof.
A tenant with an unsettled write intent is deferred to the next pass rather than
purged under an in-flight write. A verifier re-checks the result.
What survives, and why, is enumerated in the published policy: the workspace tombstone (accountability, Art. 5(2)), deletion-request records with the requester's e-mail hashed, billing records for 7 years (Art. 17(3)(b), with names, e-mails and addresses dropped before storage), and PII-free cost ledgers.
src/ataski/offboarding_purge_plan.py owns the lifecycle dimension —
purge, retain-scrub, or keep.src/ataski/data_classification.py owns the export dimension, and is
fail-closed: every exported table's full column set is inventoried, and a live
tripwire in the pre-push gate fails on any column the inventory does not know. A new column
cannot silently start flowing into, or silently vanish from, the customer
export.Both are validated against the live schema in the database stage of the
pre-push gate (scripts/check.py). That is what stops the three
ideas of "what tenant data exists" — the schema's, the purge's and the export's
— from drifting apart, which the 2026-07-14 audit found to be the biggest
systemic theme.
Export at /api/export — the path the DPA, the Terms and the privacy policy all
promise — which redirects to the per-tenant JSON archive. Individual deletion for
outreach recipients at /privacy/optout, within 30 days (GDPR) or 45 days
(CCPA). Vendor cache propagation is part of the individual-deletion path, not an
afterthought.
Waitlist and contact-sales addresses (waitlist_signups,
enterprise_inquiries) belong to no workspace, so no purge reaches them, no
retention job ages them out, and the runtime role cannot delete them (0153,
0601). Ataski is the controller here, so the request comes in under Privacy
Policy §13.3 — usually an email to security@ataski.com. To honour it:
/admin/privacy/erasure, enter the address, press Erase. It
deletes every row for the address in both tables (case-insensitive, after
the same normalisation the forms apply) and, in the same transaction,
writes one gdpr.public_form_subject_erased audit row under
PLATFORM_TENANT_ID that holds only the address hash. Without
PLATFORM_TENANT_ID it refuses and deletes nothing. A repeat is a recorded
no-op. The opt-out register is not touched.The same page lists /privacy/optout submissions for an address that is still
on one of these forms. That form proves nothing about who typed the address, so
it does not delete by itself: verify as in step 1, then erase from the list.
Backups are the seam. A restored snapshot can reintroduce rows a purge removed,
and nothing today documents how a deletion request is re-applied after a restore.
That belongs in the restore drill (see business-continuity.md) and is the one
retention question this policy cannot yet answer.