Skip to main content
All posts

Enforcing a 30-Day Privacy Retention Rule with a Daily Purge

How we made our privacy policy's 30-day retention real: one source for the days, a daily purge job, and logs and backups.

IntentCode··12 min read

TL;DR

  • We built a daily purge job so the "30-day retention" in our privacy policy is actually enforced
  • The retention days live in one file, and both the purge code and the policy text read them
  • The real work was lining up the easy-to-miss places, like logs and backups, with the policy

We read the code before writing the policy

The IntentCode website has an AI chatbot that answers questions about the company and its products, plus a blog newsletter. Both can hold personal data. The chatbot stores conversations in our database, and the newsletter stores subscribers' email addresses.

Until recently, the "Privacy Policy" link in the site footer was a disabled "coming soon" label. When we wrote the policy, we followed one rule: describe only what the code actually does. So before writing a single sentence, we checked in the code what data we keep, where, and for how long.

That check exposed the retention problem. There was no code that deleted old conversations at all. The moment a policy says "deleted after 30 days," that sentence becomes a promise the system has to keep every day. Korea's Personal Information Protection Act (PIPA), Article 21, also requires personal data to be destroyed without delay once its retention period has passed.

Retention days live in one file

The first step was to keep the number in one place. If the policy text says "30 days" and the purge code has its own 30, one of them will eventually change without the other. Then the policy and the real behavior drift apart quietly.

ts
// lib/retention-policy.ts
// Shared by the purge (lib/db/retention.ts) and the policy text (lib/privacy-content.ts)
export const CHAT_RETENTION_DAYS = 30;
export const NEWSLETTER_RETENTION_DAYS = 30;

The policy text imports these constants to build its sentences. For example, the 30 in "deleted automatically 30 days after the last message" comes from CHAT_RETENTION_DAYS. The Korean, English and Japanese policies all use the same constants, so changing the number updates all three languages at once.

One more note: the Korean policy is written in a formal register, unlike the rest of our site, because it is a legal document. The Korean version prevails, and the English and Japanese versions are marked as reference translations.

Deciding what to delete, and when, in SQL

The rule differs a little by data type. Chatbot conversations are deleted 30 days after the last message. Newsletter addresses are kept while you are subscribed and deleted 30 days after you unsubscribe. Signups that never clicked the confirmation email are deleted 30 days after signing up.

ts
// lib/db/retention.ts (condensed)
await query(
  `DELETE FROM conversations
    WHERE last_active_at < now() - make_interval(days => $1)
    RETURNING id`,
  [CHAT_RETENTION_DAYS],
);
await query(
  `DELETE FROM newsletter_subscribers
    WHERE (status = 'unsubscribed' AND unsubscribed_at < now() - make_interval(days => $1))
       OR (status = 'pending' AND created_at < now() - make_interval(days => $1))
    RETURNING id`,
  [NEWSLETTER_RETENTION_DAYS],
);

Each conversation has several messages and answer ratings attached to it. Those tables were created with ON DELETE CASCADE on their foreign keys (columns that point to a row in another table). In PostgreSQL, this means that when the referenced row is deleted, the rows pointing to it are deleted too. Deleting a conversation never leaves orphaned messages or ratings behind.

make_interval(days => $1) is a PostgreSQL function that builds an interval from a day count passed as a parameter. We can pass the number directly instead of building strings. The response only reports how many rows were deleted, and the deleted content is never logged anywhere.

A scheduled job calls it once a day

The purge sits behind a single admin API. Without the shared secret it returns 401, and calling it again only removes rows that have already expired, so the result is the same. This "safe to run again" property is called idempotency.

A GitHub Actions scheduled workflow calls the API every day. It follows the same pattern as our existing scheduled-publish job, so there was little new to learn.

yaml
on:
  schedule:
    - cron: '30 18 * * *' # 03:30 KST every day
  workflow_dispatch: {}   # run it by hand when needed

GitHub Actions cron runs in UTC by default, so 3:30 a.m. in Korea is written as 18:30 UTC. In practice, most runs started three to four hours after the scheduled time. GitHub's docs also say scheduled runs can be delayed when Actions is under heavy load. For a once-a-day purge, a few hours of delay is fine, but for a job that needs an exact time we would have picked another trigger.

The first run was a special case, because it deleted every conversation and newsletter row older than 30 days in one pass. Before merging, we created the same schema in a throwaway PostgreSQL container and checked that 31-day-old rows were deleted while recent ones stayed.

Logs and backups follow the policy too

Deleting database rows is not the whole job. If raw chat text is printed to the server logs, the same words survive there no matter how cleanly the tables are purged. We found one line that logged the user's original input when a short keyword question was rewritten. Now it only records that the rewrite happened, and subscriber emails are no longer logged either.

Backups are the opposite problem: they are a place we cannot purge row by row. The database is backed up daily, and backup files are deleted automatically after 30 days. That means a conversation deleted today can remain in a backup for up to 30 days. We did not hide this; the policy says so plainly.

What is still left to do

Lining up the policy and the code also made the remaining work clear. Questions sent to the chatbot are transferred to AI APIs abroad to generate answers. The policy describes this transfer and how to refuse it (simply don't use the chatbot), but we do not yet mask personal data inside a question before it is sent. So we added a short note under the chat input asking people not to type personal information.

The lesson is simple. A privacy policy is a spec the system must keep every day, before it is a legal document. Keep the numbers in one place, let a scheduled job do the deleting instead of a person, and be honest about the places you cannot delete. That is what keeps the promise for the long run.

References

Get new posts by email

Notes from building our products, about twice a week. Questions? Ask Dot.