Near the bottom of our terms of service there is a sentence that is easy to write and surprisingly specific in what it commits you to:
If a change is material we will email account holders and show a notice in the app, and the version and date above will change.
Three promises in one sentence: an email, an in-product notice, and a visible version. Here is the whole machinery that keeps them, because it turned out to be smaller than the sentence.
The version is two constants
export const TERMS_VERSION = '1.3';
export const PRIVACY_VERSION = '1.3';
Both documents are currently at 1.3, which you can read off the top of the terms and the privacy policy right now. The pages render the constant, so the version on screen cannot disagree with the version the code believes in.
When an account is created, the versions it accepted are recorded in a user_consent row. That gives one predicate that answers both of the remaining promises: is this account behind?
Being behind shows the passive re-consent notice on the next visit. Being behind also puts you on the mailing list, and the mailing list is a SQL query.
The query is a !=, not a <
const rows = await db
.select({ email: userConsent.email })
.from(userConsent)
.where(
or(
ne(userConsent.termsVersion, TERMS_VERSION),
ne(userConsent.privacyVersion, PRIVACY_VERSION),
),
);
The versions are stored as text, so the obvious predicate, "recorded version is less than the current version", is a lexicographic comparison waiting to be wrong. '1.10' < '1.9' is true as a string and false as a version, and the first time a document reaches its tenth revision you silently stop emailing the people who need it most.
"Not equal to what we publish today" needs no ordering at all, and it is also the honest statement of the thing: we have a record of what you agreed to, and it is not what is on the page now.
Note also what the select does not fetch. One column. The script needs addresses, it is not a report, and a script a human runs against a production database should pull the narrowest thing that does the job.
The list drains itself
const emails = Array.from(
new Set(rows.map((r) => r.email).filter((e) => e && e.includes('@'))),
);
console.log(
`Notifying ${emails.length} account(s) behind terms v${TERMS_VERSION} / privacy v${PRIVACY_VERSION}.`,
);
The file's header calls this "idempotent-ish", which is my favourite kind of honest comment:
// Idempotent-ish: it targets everyone still on an older version, so re-running
// only re-emails those who have not signed in since (which re-records their
// acceptance).
There is no "notified" column, no send log, no dedupe table. The reason is that the behaviour we want falls out of the consent record: signing in after a bump re-records the accepted version, so the act of coming back and seeing the notice is what takes you off the list. Run it twice in an afternoon and you annoy a handful of people who have not opened the app. Run it twice a week apart and the second run is mostly empty by itself.
That is a nicer property than an idempotency table, because the state that prevents the second email is the same state that proves consent. One source of truth, used by a mail script, a UI banner and an audit question.
It is deliberately not a cron
The header says FOUNDER-RUN, and the usage block says to run it after bumping the constants and deploying the updated pages:
pnpm --filter @munchable/web exec tsx scripts/notify-terms-change.ts
Two reasons it cannot be automatic. The first is that "material" is a judgment. Fixing a typo in a heading is not a thing to email thousands of people about, and no condition in the code can tell that apart from a change to the liability section.
The second is ordering. The email links to the live pages. Deploy, then mail. An automated job wired to the constant would fire on merge and send people to the version they already read.
The last time both constants moved was not a wording change at all: the publisher named in both documents changed, which you can see on the about page. That is as material as it gets, since it is a change to who the other party in the agreement is, and it is exactly the kind of change that no diff-watching automation would have classified correctly.
The loop is sequential on purpose
for (const email of emails) {
const res = await sendEmail({ ... });
if (res.success) sent += 1;
else console.error(`Failed for ${email}:`, res.error);
}
console.log(`Done. Sent ${sent}/${emails.length}.`);
No Promise.all, no batching, no queue. A script that a person watches run should fail loudly on one address and keep going, print which address failed, and end with a count that can be compared against the count it printed at the start. Two counts that do not match is a thing you can act on, with the failing addresses already on screen above it. A rejected Promise.all is not.
The whole file is under a hundred lines including the HTML email. The useful work was deciding that the recipient list is derived rather than stored, and that a human presses the button.











