- Aug 26, 2026
- 5 min read
Writing the Privacy Page Found a Retention Bug
My forms store every lead in Cloudflare KV with an expiry that matches the privacy policy: enquiries 24 months, newsletter subscriptions until the person unsubscribes (how the per-kind TTL works).
Then I added double opt-in, and went to update the privacy pages to describe it. I started writing the obvious sentences:
Subscription: until you unsubscribe. Then it’s deleted. Sign-ups you never confirm: deleted after…
…after what? I went to the code to find the number. There wasn’t one.
Two kinds of subscription that lived forever
Retention was set per kind, and a newsletter had no expiry at all:
const ttl = RETENTION_SECONDS[lead.kind]; // newsletter: undefined → no expiry
That was right for a confirmed subscriber. Double opt-in added two states it was wrong for:
pending: someone entered an address and never clicked the confirmation link. Maybe it was a typo, maybe someone else’s address. We never got consent, and the record would stay forever.unsubscribed: the person told me to stop. The lead stayed, with no expiry, holding an address I had promised to delete.
The do-not-send list already stopped any email going to an unsubscribed address (it stores hashes, not addresses). The problem was keeping the data, not sending to it.
Retention by status
The fix makes retention depend on the subscription’s status, not just its kind:
/** A newsletter sign-up that is never confirmed, or that was unsubscribed, is
* deleted after this long (the suppress:<hash> entry outlives it). */
export const SUBSCRIPTION_CLEANUP_SECONDS = 30 * 24 * 60 * 60;
export function ttlFor(lead: Lead): number | undefined {
const kindTtl = RETENTION_SECONDS[lead.kind];
if (lead.kind === "newsletter" && (lead.status === "pending" || lead.status === "unsubscribed")) {
return kindTtl ? Math.min(kindTtl, SUBSCRIPTION_CLEANUP_SECONDS) : SUBSCRIPTION_CLEANUP_SECONDS;
}
return kindTtl;
}
| Record | KV expiry |
|---|---|
Newsletter, pending (never confirmed) | 30 days |
Newsletter, confirmed | none, until unsubscribe |
Newsletter, unsubscribed | 30 days after the unsubscribe |
| Enquiry (with or without the marketing tick) | 24 months |
suppress:<hash> (do-not-send entry, no address) | none |
Two choices in that table deserve a sentence each.
Enquiries keep 24 months even when their marketing tick is pending or withdrawn. On the growth site an enquiry can carry an optional “send me updates” tick, which goes through double opt-in too. If the tick is never confirmed, the person still asked a question, and the message is kept for that purpose. Only the marketing use ends. A test pins this, because the shortcut “pending means 30 days” would have deleted real questions after a month.
The do-not-send hash never expires. It’s the one thing that keeps an unsubscribed address from being mailed again if it reappears. It holds no address, so keeping it doesn’t contradict “your data is deleted”.
Re-putting resets the TTL, and here that helps
KV has no “change the expiry” call; you write the value again. I had noted before that re-writing a value resets its TTL, as a trap. In this design it’s how the transitions work:
- Subscribe: the lead is written
pendingwith 30 days. - Confirm: it’s written again as
confirmed, with no expiry, so the 30-day clock disappears. - Unsubscribe: written again as
unsubscribed, so a fresh 30-day clock starts from the unsubscribe.
Every status change goes through the same putLead(), which asks ttlFor().
There’s no separate cleanup job to forget.
”Deleted” has to include the copies
KV isn’t the only place a lead lives. My homelab mirrors KV into SQLite and keeps daily backups (the pull-only mirror). So “deleted after 30 days” in the privacy page has to cover those too:
- The mirror marks a lead as gone when its key disappears from KV, and purges it 30 days later. That grace period protects against a bad pull wiping real data.
- Backups rotate after 14 days.
Add those up and a withdrawn subscription is gone everywhere within about three months. That’s the phrase that went into the privacy pages, instead of a neater promise that wasn’t true.
The other sentence that stopped being true
The same review caught a second mismatch, in what I said about frequency. The jalcocertech privacy page said article subscribers get new write-ups “a few times per quarter at most”. That stopped being true when I enabled a welcome series: three emails over the first week after confirming. The page now says so, with what each email is.
Nobody would have sued me over it. But a privacy page that understates how often you email people is precisely the kind of small dishonesty these pages exist to prevent (what the pages cover).
Order of work
I fixed the Worker first and deployed it. Only then did I publish the privacy text that describes it. Publishing the policy first would have meant a few days of a page promising deletions the system didn’t do.
Takeaways
- Write the policy against the code, not the plan. Every number in a privacy page should point to a constant you can find.
- New states need retention too. Adding
pendingandunsubscribedwas a retention change, even though I only thought of it as an email feature. - Protect the record of the request. A withdrawn marketing tick ends the marketing, not the enquiry.
- Count the copies. Mirrors, grace periods and backups all add to “how long until it’s really gone”.
- Re-read your promises when a feature changes behaviour, like a welcome series changing how often you email.
Next: a Mailjet event webhook that only suppresses permanent failures.