Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -41,6 +42,9 @@ jobs:
- 'package.json'
- 'yarn.lock'
- '.github/workflows/deploy.yml'
retention:
- 'workers/retention/**'
- '.github/workflows/deploy.yml'

deploy:
needs: changes
Expand Down Expand Up @@ -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'
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
31 changes: 31 additions & 0 deletions db/migrations/0004_retention_runs.sql
Original file line number Diff line number Diff line change
@@ -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 <task> 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);
8 changes: 7 additions & 1 deletion legal/privacy-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"private": true,
"packageManager": "yarn@4.18.0",
"workspaces": [
"sites/*"
"sites/*",
"workers/*"
],
"scripts": {
"dev:www": "yarn workspace @rootsystem/www dev",
Expand Down
12 changes: 12 additions & 0 deletions workers/retention/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
109 changes: 109 additions & 0 deletions workers/retention/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
},
}
12 changes: 12 additions & 0 deletions workers/retention/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2022",
"module": "es2022",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
23 changes: 23 additions & 0 deletions workers/retention/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
15 changes: 15 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Loading