DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A date is prose, a version is a key: five systems read our terms version

Most products put a date at the top of their terms page. "Last updated 8 September 2026". It is honest, it is human, and nothing in the codebase can do anything with it.

Munchable's legal pages carry a date as well, but the thing the rest of the system reads is a version number, and it lives in a file thirteen lines long:

export const TERMS_VERSION = '1.2';
export const PRIVACY_VERSION = '1.2';
Enter fullscreen mode Exit fullscreen mode

That is the whole module. Five separate parts of the product read those two constants, and bumping one of them is the entire release process for a change in terms. Here is what each of them does with it, because the interesting part is not the constant, it is how much work you get out of turning a date into a key.

1. The page prints it

<p className="updated">Version {TERMS_VERSION}. Last updated 8 September 2026.</p>
Enter fullscreen mode Exit fullscreen mode

The date is for the reader. The version is for everything else, and printing it means the number a user can see is definitionally the number the code is comparing, rather than a second copy in a CMS field somebody forgot.

Further down, the terms say what the number is for in plain language: when you create an account we record which version of each document you accepted, so both of us know what was agreed. That sentence is only true because of the next item.

2. Signing in records an acceptance

Creating an account, or signing in, fires an upsert into a small consent table:

await db.insert(userConsent).values({
  userId: user.id,
  email,
  termsVersion: TERMS_VERSION,
  privacyVersion: PRIVACY_VERSION,
}).onConflictDoUpdate({
  target: userConsent.userId,
  set: { email, termsVersion: TERMS_VERSION, privacyVersion: PRIVACY_VERSION, updatedAt: now },
});
Enter fullscreen mode Exit fullscreen mode

Two details in that upsert are worth copying. accepted_at is not in the update set, so it stays the first acceptance the account ever made, while updated_at tracks the latest recording. And the email is stored here rather than being joined from the auth provider at send time, because the one thing this table exists to support is contacting these people later.

The row holds an account id, an email, and two short strings. No condition, no scan, nothing about anybody's health. That matters because Munchable's whole architecture is built to keep health data off the server, and a compliance table is exactly the kind of thing that quietly acquires columns it should not have.

3. A passive notice, with no "I agree" button

When an existing account's recorded version is behind the current one, a banner appears: we have updated our Terms and Privacy Policy, and by continuing to use Munchable you accept them.

There is no accept button. The component records the new version the moment it decides to show the notice:

if (!d?.needsReconsent) return;
setShow(true);
// Continued use is acceptance, so record the new version now.
void fetch('/api/consent', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
Enter fullscreen mode Exit fullscreen mode

The dismiss control only hides the banner for the session; it is not a decision.

I want to defend that, because a modal with an "I agree" button feels more rigorous. It is not. Everyone clicks it without reading, so the click records nothing except that a button existed. Meanwhile the modal is a gate, and this app's job is to answer a question while somebody is standing in a supermarket aisle holding a jar. Blocking that on a legal dialog is the kind of thing that makes people close the app and buy the jar.

So the notice tells the truth about what is actually happening: the terms changed, continued use is the acceptance, and here are two links if you want to read them. The record of who was told and when is the thing that has to be solid, and that is a row in a table, not a click event.

The check itself is one endpoint:

const needsReconsent = Boolean(
  row && (row.terms !== TERMS_VERSION || row.privacy !== PRIVACY_VERSION),
);
Enter fullscreen mode Exit fullscreen mode

row && is load bearing. A brand-new account has no row at the moment the banner asks, because the sign-in upsert may not have landed yet, and without that guard every new user's first action in the product would be a notice telling them the terms they just accepted have changed. Absent means new, and new means nothing to announce.

Anonymous device sessions get false without a query, and any error in the handler returns false too. A compliance notice that fails loudly is a compliance notice that breaks the app during an incident.

4. A script emails everyone still behind

The terms page promises users will be told when the terms change, and an ongoing agreement is not fair if the other party silently rewrites it. So there is a founder-run script:

const rows = await db.select({ email: userConsent.email }).from(userConsent).where(
  or(
    ne(userConsent.termsVersion, TERMS_VERSION),
    ne(userConsent.privacyVersion, PRIVACY_VERSION),
  ),
);
Enter fullscreen mode Exit fullscreen mode

It targets everyone whose recorded version is not the current one, which makes it roughly idempotent by construction. Running it twice only re-emails the people who have not signed in since, because signing in re-records their acceptance and removes them from the set. There is no "sent" flag to maintain and no join table to get out of step, because the query is already asking the only question that matters: who has not caught up.

This is the same trick as the version number itself. Derive the set from state you already keep, rather than maintaining a second record of who you have contacted.

5. It is the fingerprint for search engines

Munchable pings IndexNow when a page's content changes, and to do that it needs a hash per URL that changes exactly when the copy does. That is straightforward for pages generated from data, because you hash the data. The legal pages are JSX, so there is nothing to hash.

Except there is:

{ path: '/privacy', hash: fingerprint(PRIVACY_VERSION) },
{ path: '/terms',  hash: fingerprint(TERMS_VERSION) },
Enter fullscreen mode Exit fullscreen mode

The version is already a hand-maintained statement that the wording materially changed, which is precisely the signal the crawler ping wants. Bumping the constant is what tells the search engines the page is new, with no separate step to forget.

For contrast, the homepage and the licenses page have their copy in JSX with no version of any kind, so they are announced once when they are new and then left alone, with a manual endpoint for the rare edit. That is the shape of the problem this trick avoids.

The honest failure mode

Forget to bump the constant and nothing happens anywhere. No notice, no email, no crawler ping, and the recorded acceptances keep pointing at a version string that no longer describes the page.

I would rather have that than a system that tries to detect materiality on its own, because "did this edit change the meaning" is a judgement, and the only place a judgement belongs is in a number a person types. What the design does is make sure that once the judgement has been made, everything downstream happens by itself.

Go and look

  • The terms and the privacy policy both render "Version 1.2" at the top. That string is the constant, not a copy of it.
  • Section 2 of the terms describes the consent record in the user's own language, which is the test I would apply to any mechanism like this: if you cannot explain it in one sentence on the page it governs, it is probably doing something it should not.
  • The about page names the company that is party to the agreement, which is the other half of "both of us know what was agreed".

If your product has a terms page with only a date on it, the cheapest possible upgrade is to put a version string beside it and store that string when someone signs up. Everything above follows from having a key instead of prose.

Top comments (0)