Retention: perform the deletions the privacy policy promises - #69
Merged
Conversation
The policy went live on rootsystem.com committing to three deletions and
nothing performed any of them. This is the Worker that does.
A separate Worker rather than a cron on the forensics site. This one
deletes data, and a fault in it must not be able to take down a request
path; it also has no fetch handler at all, so the cron is the only way
in and there is nothing to authenticate.
The three tasks match the policy clause for clause. The 90-day intake
purge blanks `matter_summary` and never deletes the row -- the conflict
record is in the same row and is kept for as long as the practice
operates, so a DELETE would quietly destroy what the conflict screen runs
against. Spam-judged intake rows are the one case that is deleted
outright: a submission judged automated carries no conflict record worth
keeping. Contact enquiries go at twelve months.
Cutoffs are evaluated by SQLite's own `datetime('now', ...)` rather than a
Date built in the Worker, so the comparison uses the same clock that
wrote `created_at` and no timezone question arises. The intake purge is
guarded on `matter_summary <> ''` so that already-purged rows are not
recounted as affected on every subsequent run, which would make the log
meaningless.
Migration 0004 adds `retention_runs`. Every task writes a row even when
it changed nothing, because a zero and a missing row are different facts:
one proves the job ran and found nothing due, the other proves it did not
run. It records counts only -- logging which rows lost a description, let
alone what they said, would rebuild a shadow of the thing being deleted.
Tasks are independent. One failing does not skip the others, since a
transient error on one statement is no reason to defer a deletion that is
due today.
Verified against a seeded local database: a 100-day-old matter had its
description blanked and its row kept, a 10-day-old one was untouched, a
400-day-old spam row and a 400-day-old contact enquiry were deleted, and
10-day-old equivalents survived. A second run recorded zeros for all
three tasks, which is the idempotency check.
CI deploys it from primary only; a pull request gets `--dry-run`, because
a version upload of a cron-only Worker produces nothing to look at while
a dry run still catches config and bundling errors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53xszP5CdZLXfhRJWsSyv
Adding `workers/*` to the workspace globs changes resolution, and CI installs with `--immutable`, so the lockfile has to move with it. The only addition is `@cloudflare/workers-types`, which is a type-only dependency: wrangler strips types when it bundles, so nothing ships from it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J53xszP5CdZLXfhRJWsSyv
It read "deleted twelve months after they were received, unless they relate to a live matter or engagement". Nothing in this database can evaluate that condition -- the same flaw the intake clause had, and the same answer: the exception goes rather than the schema growing to support it. Both deletions are now unconditional, which is the only shape a commitment can take when the system making it cannot tell the exception apart from the rule. It also makes the policy match what workers/retention actually does, which it did not before this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J53xszP5CdZLXfhRJWsSyv
* origin/primary: chore(db): adopt wrangler migration tracking after the fact
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #67.
What
workers/retention— a cron-only Worker performing the three deletions the live policy commits to, plus migration 0004 for the run log, plus a CI job to deploy it.Stacked conceptually on #68 (migration tracking); 0004 is the first migration that will land through
wrangler d1 migrations apply.Why a separate Worker
It deletes data. A fault in it must not be able to take down a request path. It also has no
fetchhandler at all, so the cron is the only way in and there is nothing to authenticate or reach.The three tasks
intake_summaryUPDATE case_intake SET matter_summary = ''at 90 dayscontact_submissionsDELETEat 12 monthsintake_spamDELETEwherespam_reason IS NOT NULLat 12 monthsThe 90-day purge blanks a column and never deletes a row. The conflict record — who approached the practice, on which side, when — is in the same row and is kept for as long as the practice operates. A
DELETEthere would quietly destroy what the conflict screen runs against. Spam rows are the one exception, because a submission judged automated carries no conflict record worth keeping.Cutoffs use SQLite's
datetime('now', ...)rather than aDatebuilt in the Worker, so the comparison uses the same clock that wrotecreated_at.Evidence
retention_runsgets a row per task per run even when nothing changed. A zero proves the job ran and found nothing due; a missing row proves it did not run. Counts only — recording which rows lost a description would rebuild a shadow of the thing being deleted.Verified locally
Seeded database,
wrangler dev --test-scheduled:Final state: the 100-day matter kept its row with an empty description, the 10-day matter was untouched, the 400-day spam and 400-day contact rows were gone, and the 10-day equivalents survived.
One thing for you to decide
The policy says contact enquiries are deleted at twelve months "unless they relate to a live matter or engagement." That exception has exactly the problem you spotted in the intake clause — nothing in this database can evaluate it, so the Worker deletes unconditionally and the policy overstates what happens. Either drop the clause (one-line edit, consistent with the decision you already made) or tell me what signal should protect a row.
🤖 Generated with Claude Code
https://claude.ai/code/session_01J53xszP5CdZLXfhRJWsSyv