The entire mechanism that decides whether a returning user sees a "we have updated our policies" notice is this file:
export const CURRENT_TOS_VERSION = '1.12.0';
export const CURRENT_PRIVACY_VERSION = '1.7.0';
Each account stores the versions it last accepted. The dashboard layout compares:
const needsPolicyAcceptance =
!dbUser ||
dbUser.tos_version !== CURRENT_TOS_VERSION ||
dbUser.privacy_policy_version !== CURRENT_PRIVACY_VERSION;
Bump a string, publish, and every account whose stored version no longer matches sees the notice on its next dashboard visit. No migration, no backfill, no per-user flag to reset. The two versions move independently, so editing the privacy notice does not re-prompt anybody about the terms.
The layout used to write to the database
The first version of this did the obvious thing. The layout noticed the mismatch, issued an UPDATE to record acceptance, then inserted an audit row, then rendered.
That is a correctness problem before it is a performance problem, and the ordering of those two words is the point. React may render a component more than once. A layout re-render would re-issue both writes. The performance cost was real as well, two sequential write round trips blocking the entire dashboard subtree on the first load after any version bump, but a slow dashboard is a thing you measure and fix later. Duplicate writes during render are a thing that produces a database you cannot reason about.
So the layout is now read-only, and the notice records its own acceptance after it renders:
useEffect(() => {
if (!recordAcceptance || hasRecorded.current) return;
hasRecorded.current = true;
void fetch('/api/user/accept-policies', { method: 'PATCH' }).catch(() => {
// Non-blocking: if this fails the banner reappears on the next load
// and the write is retried then.
});
}, [recordAcceptance]);
Three things in there are deliberate.
The hasRecorded ref guards against React's development double-invoke of effects. The endpoint is idempotent, so a duplicate call changes nothing about the stored versions, but it would write a second audit row for one acceptance. An audit trail with phantom duplicate entries is worse than no audit trail, because somebody will eventually try to count them.
The failure path does nothing on purpose. If the write fails, the user sees the notice again next time and the write is retried then. There is nothing here for a user to act on, so there is nothing to tell them.
And the endpoint already existed. Moving the write out of the layout also deleted a duplicate implementation of the same four-column update, which is the sort of cleanup that only shows up once you stop and ask why the same logic is written twice.
The new-account case that looks like a bug
Notice that !dbUser sets needsPolicyAcceptance. A brand new account has no application row yet, so by that test it needs to accept. But the notice is told not to record anything:
/**
* Whether to record acceptance for this user. False when there is no database
* row yet to update (a brand-new account records acceptance at signup instead).
*/
recordAcceptance?: boolean;
The row creation stamps both versions inside the signup transaction, alongside the Stripe customer and the free interview credit. An UPDATE ... WHERE id = $1 against a row that does not exist is not an error in Postgres, it just affects zero rows, which is exactly the kind of silent no-op that is worth spending a prop to avoid.
Acceptance without a click
Our terms say what the mechanism is, in the terms themselves:
When we do, we bump the version number and "Last updated" date at the top of this page and show a notice in your dashboard the next time you sign in, linking to the updated Terms. We do not send emails about policy changes, that in-app notice is the only notification, so please read it when it appears. Your continued use of the Service after such modifications constitutes your acceptance of the updated Terms.
That is why the notice has a dismiss button rather than an "I agree" button, and why acceptance is recorded as a side effect of the notice being shown. The alternative, a blocking modal, would be honest only if we were prepared to lock people out of a product they are mid-practice in, and we are not. What we do instead is record the version, the timestamp, the IP and the user agent in the audit log, so an account's own activity export shows exactly which version it was shown and when.
See it
Open cogniprep.app/terms and read the two lines directly under the heading:
Last updated: 3 October 2026
Version: 1.12.0
Then open cogniprep.app/privacy, which carries its own pair, currently version 1.7.0. Those two numbers are the live values of the two constants above. If you have an account, the comparison in the layout is between what you see on those pages and what your account last stored, and the next time either number moves you will find out from a notice in the dashboard rather than from an email.
Top comments (0)