DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Two version numbers in one TypeScript file decide what our legal pages say about themselves, who sees a notice, and which two URLs get resubmitted

This is the whole mechanism for versioning Munchable's legal documents:

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

Two strings in a file with a comment above them saying to bump by 0.1 on any material change. You can see the current values rendered on the terms and the privacy notice, in the line under each heading.

I like this file because it is the smallest thing in the codebase with three consumers that do not know about each other, and because the interesting part is not the mechanism. It is deciding what counts as a change.

Consumer one: the documents say which version they are

<p className="updated">Version {PRIVACY_VERSION}. Last updated 3 October 2026.</p>
Enter fullscreen mode Exit fullscreen mode

The version is rendered rather than typed into the prose, which sounds trivial and is the only reason the next two consumers can be trusted. A document that states its own version from the same constant that drives the machinery cannot be the version the machinery thinks it is not.

The date next to it is still typed by hand, and those two facts sitting in one sentence, one derived and one not, is a small ongoing embarrassment I have decided to live with: the date is a claim about when a human last edited the wording, and no constant in the repo knows that.

Consumer two: whether you are asked to accept them again

Accepting the documents is recorded per account at sign-in. The route is an idempotent upsert:

await db.insert(userConsent).values({
  userId: user.id, email,
  termsVersion: TERMS_VERSION, privacyVersion: PRIVACY_VERSION,
}).onConflictDoUpdate({
  target: userConsent.userId,
  // Keep `accepted_at` as the first-ever acceptance; `updated_at` tracks the
  // latest recording.
  set: { email, termsVersion: TERMS_VERSION, privacyVersion: PRIVACY_VERSION /* ... */ },
});
Enter fullscreen mode Exit fullscreen mode

Two timestamps with different meanings is the part worth copying. accepted_at answers "since when has this person been a user who agreed to something", and updated_at answers "which version are they on now". One column cannot answer both, and the question you get asked later is always the one you did not keep.

The read side is three lines of comparison:

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

row && is doing real work there. A brand new account has no row at the moment this is first called, and without that guard every new user would be shown a notice about terms changing before they had accepted anything once. The absence of a row is not "behind", it is "not yet recorded", and the sign-in upsert handles it a beat later.

Inequality rather than a less-than comparison is also deliberate, though it took me a minute to be comfortable with it: if I ever revert a document, the version goes backwards and everybody is asked again. That is the right behaviour and it falls out of comparing strings, which is also why these are strings and not numbers.

Then there is the error branch, which is the one design decision here I would argue about with a reviewer:

} catch {
  return NextResponse.json({ needsReconsent: false });
}
Enter fullscreen mode Exit fullscreen mode

It fails open. A database blip means the notice is not shown, rather than shown to everybody or failing the request. I chose that because a legal notice that appears and disappears on a refresh teaches people to ignore legal notices, and because the notice is not the only channel: there is an email path as well. If the consent lookup is down, the honest state is "we do not know", and of the two things you can do with "we do not know", staying quiet is the one that does not cry wolf.

Consumer three: which URLs get announced to search engines

This one surprised me when I came to write the submitter. Munchable pings IndexNow when a page's content changes, and the guidance is explicit that you submit a URL when it has actually changed. Re-announcing unchanged pages is how you earn a 429 and a reputation.

So every URL on the site is fingerprinted from the data that renders it, and only the URLs whose fingerprint moved get submitted. For a question page that is a hash of the derived page. For the two legal documents it is this:

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

A page whose text is 3,200 words of hand-written prose has no derived object to hash, and hashing the rendered HTML would catch every layout tweak as a content change. The version constant is already a human saying "the wording materially changed", which is exactly the signal, and it was sitting there.

That is the argument for this kind of constant in general. A number somebody bumps on purpose is a semantic event, and semantic events are hard to infer and cheap to publish.

What a material change turned out to be

The last bump was not a policy change. It was a company name.

All five apps I publish are now published by CogniPrep Ltd, named as such on the about page and in both legal documents, because terms that do not name the counterparty are not worth much. Renaming the publishing company therefore changed a sentence in both documents, which is material by any reading: the person on the other side of the agreement is different.

So a rename that took twenty minutes of find and replace also meant: two documents reprinted at a new version, two URLs resubmitted to the engines, and every signed-in account due to be told. That is the correct amount of consequence, and it is only because the trigger is one constant that all of it happened without me remembering each piece.

The weak link is a human

Here is the part the mechanism does not cover. The email is a script, and the script is run by hand:

// FOUNDER-RUN. Emails every account whose recorded Terms/Privacy version is
// behind the current one, so users are notified when the terms change.
//
// Run AFTER bumping TERMS_VERSION / PRIVACY_VERSION and deploying the pages.
Enter fullscreen mode Exit fullscreen mode

It is written to be safe to re-run, because it targets whoever is still behind, and anybody who has signed in since the bump has already re-recorded their acceptance and drops out of the query. It is also, as stated, dependent on me remembering a command. I have already found one occasion where the two things the privacy notice promises to do on a change did not both happen, which I wrote up at the time.

The obvious fix is to make the deploy do it, by comparing the deployed constant against the last value a job recorded. I have not done it, and the reason is worth being explicit about rather than dressing up: an automated mail-everybody path that fires on a constant changing is a loaded weapon pointed at my own user list, and a typo in a version number should not be able to mail every account on the system. A chore that a human has to do is slow and visible. An automation that does it is fast and silent, and legal notices are a place where I would rather be slow.

The gap I would fix first

Consent records a version number, not a document. If somebody asks what exactly they agreed to in version 1.2, the answer is a git log on one file, and that is fine for me and not fine for a company of any size. The stronger design is to store a hash of the rendered document text next to the version, so the record points at content and not at a label, and to keep the rendered text somewhere that is not only the repository.

All of the above hangs off two string constants that any reviewer would skim past. The two documents are here and here, with their current versions printed under the headings, which is the one line of the whole system that a reader is supposed to notice.

Top comments (0)