Read this first. The Privacy Policy draft promises retention periods. This table says what each period should be and what the code actually does today. As of 2026-10-07 most rows have no automatic purge. A promise in the public policy must be backed by a job, or the promise must change. The periods below are decided (owner, 2026-10-05); the jobs are not built yet. "Code today" was checked against
apps/communication,apps/newsletter-serviceandapps/mailbox(no retention or purge job exists in any of them); rows marked [confirm] need a check I could not make from the code.
| Data | Where | Intended retention | Code today | Action |
|---|---|---|---|---|
| Account and workspace records | core DB (Neon) | while the account is active; a deleted workspace is erased 90 days after deletion (the old "30 days exportable, 60 archived" was never implemented and is dropped) | Since 2026-10-06: deleting a workspace sets status='deleted', stamps deleted_at once, revokes its API keys and repoints members' default workspace. Still true until deployed: nothing is purged. Built 2026-10-22, not deployed: cancelling billing on delete (S4); no mail sent or received from the moment of deletion (S5, P3); and 90 days after deletion its data in comms, newsletter, mailbox (including stored mail files) and contacts is erased (S5). The other services are S6. Account deletion works; only a workspace that still exists blocks it (G27) |
Gap-log G26 (billing cancel), E3 (a per-service purge after 90 days) |
| Billing and invoice records | core DB | 8 years (Indian tax law) | kept; never deleted | Fine, state it |
Email credentials (login codes, password-reset links, team-invite links), tenant system |
comms DB comms_message_logs.content |
never stored; any already stored are cleared | was stored forever (G25): the invite URL is a bearer token and nothing reads the column. Fixed and deployed 2026-10-06 for new mail: platform mail stores a body only if its type is known harmless (an allow-list, so a new secret-bearing type stores nothing by default) | The retention pass (S1, built 2026-10-21, not deployed) clears any stored after 24 hours once RETENTION_ENFORCE is on, so the one-off scripts/clear-system-mail-content.mjs is no longer needed |
| Email message content (bodies, attachments metadata) per recipient | comms DB comms_message_logs.content |
90 days (set to NULL; queued rows excluded) | no purge — kept indefinitely (audit finding CM-21). No code reads content |
built 2026-10-21 (S1, lib/retention.ts), not deployed; ships counting only (RETENTION_ENFORCE off); it runs in the sweeper's 6-hour alarm |
| Email metadata (sender, recipient, subject, status, timestamps) | comms DB | 13 months (rows deleted; logs by created_at, events by their own occurred_at, since events carry the provider message id, not a foreign key to logs) |
no purge | Same retention pass |
| Delivery-event raw payloads | comms DB comms_message_events.raw_payload |
30 days, aged by occurred_at |
no purge. Do not age by received_at: migration 0012 added it NOT NULL DEFAULT now(), so every older row carries the migration time (G36) |
Same retention pass |
| Suppression records (bounces, complaints, unsubscribes) | comms DB; newsletter DB | as long as needed to keep honouring them. Hard-bounce platform-wide rows expire after 180 days (implemented). Complaints and unsubscribes are kept. | hard-bounce expiry implemented: an expired row is ignored, not deleted, so the address stays in the table; the rest is kept | State it |
| Newsletter subscriber events (audit trail of consent and status changes) | newsletter DB nl_subscriber_events |
not time-purged. It is the proof of consent for subscribers who still exist; deleting it by age (an earlier draft said 365 days) would remove the evidence that justifies mailing them. Erased with the subscriber (address replaced by a keyed hash) or with the workspace | built 2026-10-22 (S2, newsletter lib/erase.ts + comms /internal/erase-recipient), not deployed: an erase replaces the address with a keyed hash and clears the row's details |
Tenant purge (E3) |
| Campaign send rows (one per recipient) | newsletter DB nl_campaign_sends |
tied to the campaign; deleted when a subscriber is erased and when the workspace is purged | built 2026-10-22 (S2, newsletter lib/erase.ts + comms /internal/erase-recipient), not deployed: deleted on erase, with the subscriber's link clicks and re-permission entries |
E3 |
| Subscribers and contacts | newsletter DB, contacts service | until the customer removes them or the workspace is deleted; erase on a verified data-subject request (via the customer) | newsletter: built 2026-10-22 (S2, newsletter lib/erase.ts + comms /internal/erase-recipient), not deployed. Deleting a subscriber now erases them: the address leaves every newsletter table and comms' copies of the newsletter mail sent to it; only a keyed hash in the event log and any suppression entry remain. Before this, the delete kept the address in the event log (G35); an erase also cleans those old rows. Contact-intelligence "delete" is still a soft delete that keeps phone ids and an LLM profile (G28) |
Hard-delete contacts (G28) |
| Mailbox messages and attachments | mailbox DB + R2 | while the mailbox is active; deleted with the workspace | kept; no message, thread or attachment delete and no R2 delete anywhere (G30) | Per-tenant R2 prefix purge, then rows (E3) |
| Forms submissions | forms DB | until the customer deletes them or the workspace is deleted | per-submission delete exists; a deleted workspace's forms, submissions, deliveries and view counts are erased 90 days after deletion (built 2026-10-22, data-lifecycle S6, not deployed). Deleting one form only hides it, so its submissions stay (G31) | Uploaded files: the media step |
| Comment IP address and browser string | engagement DB eng_comments.source_ip, user_agent |
30 days, then cleared; the comment, name and email stay | built 2026-10-22 (S3), not deployed: the billing ticker calls /cron/purge-comment-ips daily; it only counts until engagement's RETENTION_ENFORCE is "true". Before this the job existed but nothing called it (G32), so the public engagement privacy page's "30 days" was not yet true |
Deploy, read a day of counts, switch on |
| Flow run history (trigger payload, each step's input and output) | flows DB automation_runs, automation_run_steps |
90 days; runs deleted with their steps | built 2026-10-22 (S3), not deployed: daily via the billing ticker (/cron/purge-runs), in batches; only counts until flows' RETENTION_ENFORCE is "true". Before this it hung off a per-minute cron that was never enabled (G32) |
Deploy, read a day of counts, switch on |
| Chat transcripts | chat DB | customer-configured; default [decide] | [confirm] | Decide |
| Server and edge logs (IP, route, status, latency) | Cloudflare / Worker logs | whatever the plan keeps; the policy says "kept briefly" until the real period is read (G64) | set by Cloudflare's log retention for the plan in use (days, not necessarily 30); not controlled by us. Worker log lines can contain recipient addresses | Read the real retention; state it |
| Rate-limit counters | manager DB rate_limit_buckets |
hours | pruning [confirm] | Confirm the cleanup runs |
| Backups | Neon point-in-time recovery | per Neon plan window [confirm]. Erased data persists in backups until they age out of that window | managed by Neon | Confirm the window; state it in the Privacy Policy |
Not yet covered by any row above
Each of these holds personal data and has no schedule yet. Add a row (period, code today, action) when its owner decides one:
- Blog and page comments: the commenter's name, email and comment text, kept until the workspace is purged (the IP and browser string have their own row above).
- Contact-intelligence profiles and memories (mood, churn and other derived fields), beyond the soft delete in G28.
- The AI knowledge base: text chunks and embeddings (brain service).
- Connected-account tokens for social publishing (content-engine).
- Media files: abandoned uploads, archived private files and never-sent mail attachments are cleaned every 6 hours by media's janitor (live since 2026-10-12); every other file stays until its workspace is purged.
- The core database's waitlist and feedback tables, and one-time codes in
verifications. - The internal operations audit log (it stores an IP address).
comms_message_logs.recipientfor platform mail, which the retention pass will delete only at 13 months.
Rules
- The public Privacy Policy states a period only if the row above has a job that enforces it (or the work is scheduled with a date).
- A deleted account's data is deleted from every service, not only the core DB. Today that is unverified.
- When a customer is the controller (their subscribers, their form submitters), a data-subject request is handled with the customer, and we delete from every service once they instruct us to.
- Never extend a retention period silently; changing one needs a Privacy Policy update and notice.
- Cancelling a plan and deleting a workspace are different events. Cancelling deletes nothing. Deleting takes the workspace out of the dashboard, revokes its API keys and starts a 90-day clock
(
tenants.deleted_at) after which each service purges it. Billing and tax records are never purged (8 years). - The consent audit trail is not purged by age (see its row); it leaves only with its subscriber or its workspace.
Build order (decided 2026-10-05; the periods are the owner's: content 90 days, metadata 13 months, raw event payloads 30 days)
The sequencing and gates are in the SES-readiness gap plan (section 17 of the plan file) and tracked in
../communication/ses-production-access/gap-log.md.
- No credentials in mail logs (G25), then the retention pass: payloads at 30 days, content at 90 days, rows at 13 months (G14), behind
RETENTION_ENFORCE. A real erase path for a subscriber with a hashed trail (E1, G35).Built 2026-10-22 (data-lifecycle S2), not deployed.- A
deleted_atclock (E2), then a per-service purge of a workspace 90 days after deletion (E3), one tenant per pass, behindTENANT_PURGE_ENFORCE. Schedule the three retention jobs that exist but never run (G32).Built 2026-10-22 (data-lifecycle S3), not deployed; media's was already running.
These use Durable Object alarms; the Cloudflare account is at its cron and queue limits, so do not assume a new cron or queue.