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 b4b75a6..3023d1e 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,32 @@ correct for an empty database, so bootstrap only what already has the schema. 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/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', 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 3735fb9..570091c 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" + } + ] +} 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"