Two of the GDPR rights our users have are buttons in the app. Export your data, and delete your session history. Both of them write a row to this table before they return:
export const privacyAuditLogTable = pgTable('privacy_audit_log', {
id: uuid('id').primaryKey().defaultRandom(),
user_id: text('user_id').notNull().references(() => usersTable.id),
event_type: text('event_type').notNull(), // 'data_export' | 'session_history_deleted' | 'account_deleted'
detail: jsonb('detail'), // optional metadata (e.g. { session_count: 12 })
ip_address: text('ip_address'), // logged at request time for audit trail
created_at: timestamp('created_at').notNull().defaultNow(),
}, (t) => [
index('idx_privacy_audit_user_id').on(t.user_id),
]);
Six columns. An append-only record of the privacy actions a user has taken, which is the thing you want to have when somebody asks, a year later, whether their deletion request was actually carried out.
Four things about it are worth more than the table is.
1. The export contains the audit log, including its own entry
The export action gathers everything belonging to the user, and the last item in its payload is this:
db.select().from(privacyAuditLogTable).where(eq(privacyAuditLogTable.user_id, user.id)),
const exportPayload = {
exported_at: new Date().toISOString(),
account: { ... },
venue: { ... },
tables: venueTables,
sessions,
participants,
answers,
leaderboard_entries: leaderboard,
custom_packs: customPacks,
custom_questions: customQuestions,
privacy_audit_log: auditLogs,
}
await auditLog(user.id, 'data_export')
So the file you download lists every export you have ever requested, which means each one is one row longer than the one before it, forever. Press the button five times and the fifth file documents the previous four, with timestamps and IP addresses.
My first instinct was that this was a cute accident. It is not, and I would keep it. "What we hold about you" has to include the record of what you asked us for, or the export is incomplete in exactly the way the right of access exists to prevent. The ordering is deliberate too: the row for this export is written after the data is gathered, so a file never claims to contain a record of itself.
2. One of the three event types has no code path
Read the comment on event_type again:
event_type: text('event_type').notNull(), // 'data_export' | 'session_history_deleted' | 'account_deleted'
Three values. Grep the repository for the third one and the only hit is that comment. Nothing in the product ever writes account_deleted, because nothing in the product deletes an account.
That is not a hole, it is a decision that shows up as one. Our privacy policy routes erasure through a human: you email us, and we respond within 30 days. Two rights are self-serve buttons, the third is an inbox. For a product where deleting an account means unwinding a live Stripe subscription, a venue, other people's host invitations and a pile of sessions that other users may be members of, a button that does all of that correctly and irreversibly, in one click, at two in the morning, is a worse product than an email address.
What the comment really records is an intention. Somebody, which is to say me, wrote the string for the feature that would exist later, and it has sat in a schema comment ever since looking exactly as authoritative as the two that are real. If you are going to leave a placeholder in a schema, say so in the comment, because the next reader has no way to tell the difference between "we support this" and "we meant to".
3. There is an IP address in the privacy table
ip_address: text('ip_address'), // logged at request time for audit trail
async function getIp(): Promise<string | null> {
const h = await headers()
return h.get('x-forwarded-for')?.split(',')[0].trim() ?? null
}
An audit trail of privacy actions that is itself a collection of personal data. That is normal and defensible: the point of the record is to prove who asked for what from where, and a record you can repudiate is not an audit trail. It is also exactly the kind of thing that should be said out loud rather than discovered, which is why it is in the export payload rather than being a table only we can see.
The part I would tighten is retention. Nothing in the product ever deletes a row from this table. Our policy commits to keeping account data for up to 90 days after deletion, and the audit log is the one table whose whole value is outliving the things it describes, so those two sentences need reconciling in writing rather than being left to whoever reads the schema next.
There is also a fact about the foreign key that decides this. user_id references the users table, and this schema has no ON DELETE CASCADE anywhere in it, by choice. So the day the account deletion feature is written, deleting the user means deleting their audit rows first, which erases the trail at the exact moment it is most useful. An audit log that is deleted by the event it is most needed to prove is an audit log with one job it cannot do. The fix is to decouple it: keep the row, drop the reference, and store whatever identifier remains meaningful after the user is gone.
4. The description of the export does not match the export
The button says:
Downloads a JSON file with your account, venue, sessions, packs, and scores.
The payload has eleven keys. The five in that sentence, reading "scores" as leaderboard_entries, plus exported_at, tables, participants, answers, custom_questions and privacy_audit_log.
The two that matter are participants and answers. Those are not the account holder's data in any simple sense. They are what the people in the pub typed: the team names they chose and every answer they submitted, which our session history feature keeps precisely so a landlord can settle an argument about question fourteen a week later. Our policy treats them as pseudonymous, because a self-chosen team name is not a real identity and we never ask a player for one.
Still, a venue owner pressing "Export data" gets a file containing the quiz night behaviour of several hundred strangers, and the sentence above the button does not mention it. Nothing is leaking, the data is the venue's own records, and the fix costs one sentence of copy. But the gap between what a privacy feature says it does and what it does is the one gap in a privacy feature that is never acceptable, and it is the one I had never read closely because I wrote both halves.
What I would take from this
A privacy feature is a claim, and the code is the only thing that can honour it. Three cheap checks that found all four of these:
- Read your export payload keys next to your UI copy, out loud. They are usually written months apart.
- Grep every documented enum value for a writer. A value nothing writes is either dead or aspirational, and the comment cannot tell you which.
- Ask what deletes each row in the table, by name. "Nothing" is a legitimate answer for an audit log. It is not a legitimate answer you are allowed to discover by accident.
Top comments (0)