Yesterday's commit on CogniPrep was 100 files and
18,410 insertions: an applications tracker, a calendar feed, Google Calendar
sync and a seeded first application from the onboarding answers. All of that
lives behind a login, so a reader of this post cannot see any of it.
What a reader can see is the paperwork, and the paperwork is the part of a
feature that has to be right in public. Here is every file in that commit whose
output a user can verify, and what it cost:
| File | Lines added |
|---|---|
app/privacy/page.tsx |
142 |
app/api/user/data/route.ts |
92 |
lib/jobs/data-retention.ts |
58 |
__tests__/jobs/data-retention.test.ts |
23 |
app/api/user/delete-account/route.ts |
9 |
app/api/admin/delete-user/route.ts |
7 |
__tests__/api/data-retention-route.test.ts |
6 |
lib/gdpr/audit-logger.ts |
4 |
app/api/admin/data-retention/route.ts |
2 |
lib/policies/versions.ts |
1 |
344 lines out of 18,410. Every one of them was slower to write than the feature
code, because each one is a promise rather than a behaviour.
The one-line file is the one that does something
export const CURRENT_TOS_VERSION = '1.12.0';
-export const CURRENT_PRIVACY_VERSION = '1.7.0';
+export const CURRENT_PRIVACY_VERSION = '1.8.0';
The policy page tells readers exactly what we do when the policy changes, and it
is two things:
When we do, we:
- Update the version number and the "Last updated" date at the top of this page
- Show a notice in your dashboard the next time you sign in, linking to the updated policy
We do not send emails about policy changes. The in-app notice is the only
notification, so please read it when it appears.
I wrote about this page a few days ago, and the finding then was unflattering:
the version constant had last changed on 12 September, while four later commits
had edited the policy text, including one that added a whole new category of
data. Four edits, no bumps, no banners.
This time both halves happened, and the first half is checkable from outside:
curl -s https://cogniprep.app/privacy | grep -o 'Last updated:[^<]*' | head -1
# Last updated: 29 October 2026 ยท Version: 1.8.0
47 days between bumps. The lesson I actually take from that is not "remember to
bump the constant", it is that the constant should be derived from a hash of the
policy content rather than typed by whoever edited the page.
Section 12 is written for the person, not the regulator
The new section is live at
cogniprep.app/privacy#applications-and-calendar.
It lists what an application row holds, in the words the UI uses: the employer,
the role, the sector derived from the role, the stage, whether it is still open,
the provider named on the invitation, each date and its time zone, and for a date
you name yourself, the name, "capped at 80 characters".
That cap is in the policy because it is in the schema. So are "3 days and 1 day
before each date", and "2 years after you last changed them". Four numbers in the
prose, four constants in the code:
export const EARLY_REMINDER_DAYS = 3;
export const FINAL_REMINDER_DAYS = 1;
export const APPLICATION_RETENTION_YEARS = 2;
Nothing asserts that the page and the constants agree. That is the honest gap
left in this commit, and it is the same shape as the version bump: a fact stated
twice in two languages, with no test in between.
The narrowest scope is a sentence you get to write
The calendar sync asks Google for calendar.app.created rather than full
calendar access. That choice is not about security theatre, it is about which
sentence ends up in section 12:
We create one calendar called "CogniPrep" in your Google account and add,
change and remove your application dates and practice days in it. To keep it up
to date we read back the events in that calendar only. We cannot see, read or
change any of your other calendars or events.
With a broader scope, "we cannot see your other calendars" becomes "we choose not
to look at your other calendars", which is a worse promise and is not independently
checkable by the reader. The scope is.
The same section has to name the storage: an encrypted refresh token, AES-256-GCM,
"never shown, logged or included in your data export". Writing that sentence is
how the next three files got their lines.
The export has to keep what the policy claims
The export route gained three tables. Two of them are selected column by column
rather than with select(), and the comments say why:
// Calendar settings. The feed's URL is left out on purpose: it is a
// credential, and an export is a file people forward and upload.
db.select({
hide_employer_names: calendarFeedsTable.hide_employer_names,
include_practice_days: calendarFeedsTable.include_practice_days,
last_fetched_at: calendarFeedsTable.last_fetched_at,
created_at: calendarFeedsTable.created_at,
}).from(calendarFeedsTable).where(eq(calendarFeedsTable.user_id, userId)).limit(1),
// Which calendar accounts are connected, never the stored grant.
db.select({
provider: calendarConnectionsTable.provider,
account_email: calendarConnectionsTable.account_email,
status: calendarConnectionsTable.status,
last_synced_at: calendarConnectionsTable.last_synced_at,
created_at: calendarConnectionsTable.created_at,
}).from(calendarConnectionsTable).where(eq(calendarConnectionsTable.user_id, userId)),
A GDPR export is one of the few places where "give the user everything" is the
wrong default. The feed address and the refresh token are both things that act on
your behalf if someone else holds them, and the file this route produces is a
download people mail to themselves. So the export says a connection exists, which
account it belongs to, and when it last synced, and stops there.
The applications themselves go out in full, including one field the UI never
shows: automaticDeletionAt, the row's own retention deadline. If we are going
to delete your data on a date, you should be able to read that date.
A cascade cannot revoke someone else's grant
Account deletion in this codebase is a database cascade, which is tidy right up
until a row represents permission held by a third party. Deleting our copy of a
Google grant does not tell Google anything. So both deletion routes now call out
first:
// The cascade removes our copy of a Google Calendar grant but cannot
// revoke it at Google, so that happens first. Best effort: a failure here
// must not stop the account being deleted.
await disconnectGoogleCalendar(user!.id).catch((error) =>
logError('Failed to disconnect Google Calendar before account deletion', error)
);
Two decisions in six lines. It runs before the transaction, because an HTTP call
to Google has no business inside a database transaction. And it swallows its own
error, because "we could not reach Google" is not a reason to refuse somebody
their account deletion. The policy then documents the other direction too: if you
revoke our access in your Google account first, we can no longer reach the
calendar we created, so you will want to delete it yourself.
The nightly retention sweep got the same treatment as the existing cleanups: the
deadline lives on the row and is pushed forward whenever you edit the application,
so a tracker you are still using never expires under you, and the job's result
object and the admin endpoint's JSON both gained an applicationsDeleted counter.
A number that is usually zero is worth printing; a number nobody prints is a
promise nobody can audit.
Finally, the activity log learned one new resource type:
// A grant to write to a third-party calendar (Google). Connecting and
// disconnecting are consent changes the user can see in their activity export.
| 'calendar_connection';
See it for yourself: read
section 12, then run
the curl | grep above and compare the version line against the one quoted in
the Changes to This Policy section. If a product
tells you what it does when its policy changes, that is a claim you can hold it
to, and ours was wrong for 47 days.
Top comments (0)