From 4cd4d3890b0351be50fc503d3804868c97439849 Mon Sep 17 00:00:00 2001 From: Rob Jacques <2531474+robbyrob42@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:09:10 -0700 Subject: [PATCH 1/3] feat(retention): perform the deletions the privacy policy promises 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) Claude-Session: https://claude.ai/code/session_01J53xszP5CdZLXfhRJWsSyv --- .github/workflows/deploy.yml | 37 +++++++++ README.md | 26 ++++++ db/migrations/0004_retention_runs.sql | 31 ++++++++ package.json | 3 +- workers/retention/package.json | 12 +++ workers/retention/src/index.ts | 109 ++++++++++++++++++++++++++ workers/retention/tsconfig.json | 12 +++ workers/retention/wrangler.jsonc | 23 ++++++ 8 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 db/migrations/0004_retention_runs.sql create mode 100644 workers/retention/package.json create mode 100644 workers/retention/src/index.ts create mode 100644 workers/retention/tsconfig.json create mode 100644 workers/retention/wrangler.jsonc diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9ecf970..c804079 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -25,6 +25,7 @@ jobs: outputs: www: ${{ steps.filter.outputs.www }} forensics: ${{ steps.filter.outputs.forensics }} + retention: ${{ steps.filter.outputs.retention }} steps: - uses: actions/checkout@v5 - uses: dorny/paths-filter@v3 @@ -41,6 +42,9 @@ jobs: - 'package.json' - 'yarn.lock' - '.github/workflows/deploy.yml' + retention: + - 'workers/retention/**' + - '.github/workflows/deploy.yml' deploy: needs: changes @@ -116,3 +120,36 @@ jobs: # deliberately: it is either `deploy` or `versions upload`. run: | fnox run -- sh -c 'wrangler $DEPLOY_CMD' + + # The retention job, deployed on its own rather than through the site matrix. + # It is not a site: there is nothing to build, no workspace to name, and the + # matrix's working directory and build step do not apply to it. + # + # A pull request gets a dry run rather than a version upload. Uploading a + # version of a cron-only Worker produces nothing anyone can look at, and the + # thing actually worth catching before merge is a config or bundling error, + # which --dry-run catches. Only primary deploys, and deploying is what + # registers the cron trigger. + deploy-retention: + needs: changes + if: needs.changes.outputs.retention == 'true' + name: deploy (retention) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: jdx/mise-action@v3 + + - name: Install + env: + YARN_ENABLE_HARDENED_MODE: ${{ github.event_name == 'pull_request' && '1' || '0' }} + run: yarn install --immutable + + - name: Deploy + working-directory: workers/retention + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + DEPLOY_ARGS: ${{ github.ref == 'refs/heads/primary' && 'deploy' || 'deploy --dry-run' }} + # Word-split deliberately, the same way the site deploy is. + run: | + fnox run -- sh -c 'wrangler $DEPLOY_ARGS' diff --git a/README.md b/README.md index f0064b6..59aec6b 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,32 @@ production. free-text descriptions of live matters. Read it before adding columns or exporting rows. +## Retention + +`workers/retention` is a cron-only Worker that performs the deletions the +privacy policy commits to: the free-text matter description at 90 days, +contact enquiries and spam-judged intake rows at twelve months. It has no +fetch handler, so the cron is the only way in. + +The 90-day purge blanks `case_intake.matter_summary` and never deletes the +row. The conflict record lives in the same row and is kept for as long as the +practice operates; a DELETE there would destroy what the conflict screen runs +against. + +Every task writes a row to `retention_runs` even when it changed nothing, so a +zero means the job ran and found nothing due and a missing row means it did not +run. That table is the evidence that the policy is honored; read it for gaps in +the dates. + +To exercise it locally, against a seeded local database rather than production: + +```bash +cd workers/retention +npx wrangler d1 migrations apply rootsystem-forms --local +npx wrangler dev --test-scheduled +curl http://localhost:8787/__scheduled +``` + ## Secrets and configuration | Name | Mechanism | Notes | diff --git a/db/migrations/0004_retention_runs.sql b/db/migrations/0004_retention_runs.sql new file mode 100644 index 0000000..2458a64 --- /dev/null +++ b/db/migrations/0004_retention_runs.sql @@ -0,0 +1,31 @@ +-- rootsystem-forms — evidence that the retention job ran +-- +-- The privacy policy commits to deleting the free-text matter description at +-- 90 days and contact enquiries at twelve months. A deletion that leaves no +-- trace cannot be evidenced to anyone who asks whether that commitment is +-- honored, and "the code says it does" is not evidence that it did. +-- +-- One row per task per run, including runs that deleted nothing -- a zero is +-- the proof that the job executed and found nothing due, which is a different +-- fact from the job never having run. Reading this table for a gap in the +-- dates is how you find out the cron stopped firing. +-- +-- Deliberately not a log of what was deleted. The whole point of the purge is +-- that the matter descriptions stop existing; recording which rows lost one, +-- let alone what they said, would rebuild a shadow of the thing being deleted. +-- Row counts only. + +CREATE TABLE IF NOT EXISTS retention_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + -- 'intake_summary' | 'contact_submissions' | 'intake_spam'. Text rather + -- than a constrained set because D1 is SQLite and the task list will change + -- as the policy does; the worker owns the vocabulary. + task TEXT NOT NULL, + rows_affected INTEGER NOT NULL, + ran_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- The question asked of this table is always "when did last run", so +-- the index leads with the task and orders within it. +CREATE INDEX IF NOT EXISTS idx_retention_runs_task + ON retention_runs (task, ran_at DESC); diff --git a/package.json b/package.json index 3735fb9..ca9613f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "private": true, "packageManager": "yarn@4.18.0", "workspaces": [ - "sites/*" + "sites/*", + "workers/*" ], "scripts": { "dev:www": "yarn workspace @rootsystem/www dev", diff --git a/workers/retention/package.json b/workers/retention/package.json new file mode 100644 index 0000000..5ee7dff --- /dev/null +++ b/workers/retention/package.json @@ -0,0 +1,12 @@ +{ + "name": "@rootsystem/retention", + "version": "3.0.0", + "private": true, + "type": "module", + "scripts": { + "deploy": "wrangler deploy" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250109.0" + } +} diff --git a/workers/retention/src/index.ts b/workers/retention/src/index.ts new file mode 100644 index 0000000..494d87f --- /dev/null +++ b/workers/retention/src/index.ts @@ -0,0 +1,109 @@ +/** + * The retention job for rootsystem-forms. + * + * The privacy policy at rootsystem.com/privacy commits to three deletions. + * This Worker is the thing that performs them. Until it was deployed the + * policy was a promise nothing kept, which is why it exists at all. + * + * Design notes, each of which is a decision rather than a default: + * + * No fetch handler. A scheduled-only Worker has no HTTP surface, so there is + * nothing to authenticate and nothing to reach. The cost is that it cannot be + * triggered by hand in production; the answer to "did it run" is the + * retention_runs table, not a request. + * + * The 90-day intake purge is unconditional -- there is no "unless it became an + * engagement" test, because this database is a lead inbox and does not know + * which enquiries became matters. That was decided rather than overlooked; see + * the policy and issue #67. It rests on matters being carried into the + * engagement record before day 90. + * + * The intake purge blanks a column and never deletes a row. The conflict + * record -- who approached the practice, on which side, and when -- lives in + * the same row and is retained for as long as the practice operates. A DELETE + * here would quietly destroy the thing the conflict screen runs against. + * + * Every task writes a row to retention_runs even when it changed nothing. A + * zero proves the job ran and found nothing due; a missing row proves the job + * did not run. Those are different facts and the table has to tell them apart. + * + * Tasks are independent. One failing does not prevent the others, because a + * transient D1 error on one statement is not a reason to skip a deletion that + * is due today and would otherwise wait until tomorrow. + */ + +interface Env { + DB: D1Database +} + +/** One unit of retention work: a statement, and the name it logs under. */ +type Task = { + /** Recorded in retention_runs.task. Stable -- it is how history is read. */ + name: string + /** Human-readable, for the log line only. */ + description: string + sql: string +} + +/** + * The three commitments, in the order the policy states them. + * + * Dates are compared with SQLite's `datetime('now', ...)` rather than a value + * computed in JavaScript, so the cutoff is evaluated by the same clock and + * calendar that wrote `created_at`. A Date built in the Worker would introduce + * a second notion of "now" and a timezone question that does not need to exist. + */ +const TASKS: Task[] = [ + { + name: 'intake_summary', + description: 'blank matter descriptions older than 90 days', + // `<> ''` keeps the count honest: without it every already-purged row is + // re-counted as affected on every run, and the log stops meaning anything. + sql: `UPDATE case_intake + SET matter_summary = '' + WHERE matter_summary <> '' + AND created_at <= datetime('now', '-90 days')`, + }, + { + name: 'contact_submissions', + description: 'delete contact enquiries older than twelve months', + sql: `DELETE FROM contact_submissions + WHERE created_at <= datetime('now', '-12 months')`, + }, + { + name: 'intake_spam', + description: 'delete spam-judged intake rows older than twelve months', + // Spam rows are the one case where a case_intake row is deleted outright. + // They carry no conflict record worth keeping: a submission judged + // automated did not come from a firm that might appear opposite us later. + sql: `DELETE FROM case_intake + WHERE spam_reason IS NOT NULL + AND created_at <= datetime('now', '-12 months')`, + }, +] + +export default { + async scheduled(_event: ScheduledController, env: Env): Promise { + for (const task of TASKS) { + try { + const result = await env.DB.prepare(task.sql).run() + const rows = result.meta.changes ?? 0 + + // Logged before the insert, so that a failure to record the run still + // leaves the count in the Worker log rather than nowhere. + console.log(`retention: ${task.name} ${task.description}: ${rows} rows`) + + await env.DB.prepare( + `INSERT INTO retention_runs (task, rows_affected) VALUES (?, ?)`, + ) + .bind(task.name, rows) + .run() + } catch (error) { + // Swallowed on purpose: the next task is still due today. The failure + // shows up as a gap in retention_runs for this task, which is the + // signal to look at the Worker log. + console.error(`retention: ${task.name} failed`, error) + } + } + }, +} diff --git a/workers/retention/tsconfig.json b/workers/retention/tsconfig.json new file mode 100644 index 0000000..c250447 --- /dev/null +++ b/workers/retention/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/workers/retention/wrangler.jsonc b/workers/retention/wrangler.jsonc new file mode 100644 index 0000000..962eabf --- /dev/null +++ b/workers/retention/wrangler.jsonc @@ -0,0 +1,23 @@ +{ + // The retention job. A separate Worker from the two sites, deliberately: + // 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 it has no surface + // anyone can reach from the internet -- the only way in is the cron. + "name": "rootsystem-retention", + "compatibility_date": "2026-07-29", + "main": "src/index.ts", + "observability": { "enabled": true }, + // No workers_dev subdomain. Nothing should be able to invoke this over HTTP. + "workers_dev": false, + // Daily, well outside US business hours, so a long-running purge never + // competes with a form submission for the same rows. + "triggers": { "crons": ["17 9 * * *"] }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "rootsystem-forms", + "database_id": "218e6fe4-44bd-4d12-a630-9a2c17cc863d", + "migrations_dir": "../../db/migrations" + } + ] +} From cfc4527862c4e5c8739558287ffa010a273faad4 Mon Sep 17 00:00:00 2001 From: Rob Jacques <2531474+robbyrob42@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:10:44 -0700 Subject: [PATCH 2/3] chore: refresh the lockfile for the workers workspace 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) Claude-Session: https://claude.ai/code/session_01J53xszP5CdZLXfhRJWsSyv --- mise.toml | 5 +++-- package.json | 2 +- yarn.lock | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/mise.toml b/mise.toml index 2e2f909..ac040e1 100644 --- a/mise.toml +++ b/mise.toml @@ -2,8 +2,9 @@ node = "22" yarn = "latest" fnox = "latest" -# Cloudflare deploy tooling. Both sites publish via `wrangler deploy` against -# their own wrangler.jsonc; CI uses the same binary so local and CI deploys +# Cloudflare deploy tooling. Both sites and the retention Worker publish via +# `wrangler deploy` against their own wrangler.jsonc; CI uses the same binary +# so local and CI deploys # cannot drift. wrangler = "latest" # scripts/frontend-compare.py runs under `uv run --script`, which resolves its diff --git a/package.json b/package.json index ca9613f..570091c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "packageManager": "yarn@4.18.0", "workspaces": [ "sites/*", - "workers/*" + "workers/*" ], "scripts": { "dev:www": "yarn workspace @rootsystem/www dev", diff --git a/yarn.lock b/yarn.lock index a6007d3..a1a0e33 100644 --- a/yarn.lock +++ b/yarn.lock @@ -538,6 +538,13 @@ __metadata: languageName: node linkType: hard +"@cloudflare/workers-types@npm:^4.20250109.0": + version: 4.20260702.1 + resolution: "@cloudflare/workers-types@npm:4.20260702.1" + checksum: 10c0/2dbd1d8d99285b49f90457321cc6f3668ac2fb89c9e24b36539e60f67925611078724093176bfbe06c8ab8d98eafd4902e4faa2e96a1378f96b14498c28ccdda + languageName: node + linkType: hard + "@cspotcode/source-map-support@npm:0.8.1": version: 0.8.1 resolution: "@cspotcode/source-map-support@npm:0.8.1" @@ -1604,6 +1611,14 @@ __metadata: languageName: unknown linkType: soft +"@rootsystem/retention@workspace:workers/retention": + version: 0.0.0-use.local + resolution: "@rootsystem/retention@workspace:workers/retention" + dependencies: + "@cloudflare/workers-types": "npm:^4.20250109.0" + languageName: unknown + linkType: soft + "@rootsystem/www@workspace:sites/www": version: 0.0.0-use.local resolution: "@rootsystem/www@workspace:sites/www" From 0f4c2d7a391369201181186a85c589344037aff6 Mon Sep 17 00:00:00 2001 From: Rob Jacques <2531474+robbyrob42@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:34:58 -0700 Subject: [PATCH 3/3] policy: the contact-enquiry clause loses its exception too 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) Claude-Session: https://claude.ai/code/session_01J53xszP5CdZLXfhRJWsSyv --- legal/privacy-policy.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/legal/privacy-policy.ts b/legal/privacy-policy.ts index 8ed4a27..ab05f3f 100644 --- a/legal/privacy-policy.ts +++ b/legal/privacy-policy.ts @@ -37,6 +37,12 @@ * standing assumption about how intake is worked, and the commitment below * depends on it. * + * The contact-enquiry clause lost its exception for the same reason. It read + * "unless they relate to a live matter or engagement", which nothing in this + * database can evaluate either. 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. + * * The conflict record names the domain of the address an enquiry arrives from. * It is derived at the endpoint and stored, not asked for on the form: `firm` * is optional free text, and a website field would be one more question on a @@ -263,7 +269,7 @@ export const sections: Section[] = [ { term: 'Contact enquiries', detail: - 'Messages sent through the contact form on rootsystem.com are deleted twelve months after they were received, unless they relate to a live matter or engagement.', + 'Messages sent through the contact form on rootsystem.com are deleted twelve months after they were received.', }, { term: 'Submissions judged automated',