One SQL function to stop TypeScript and SQL from drifting apart

Search for a command to run...

Here is a bug that needs no cleverness to cause and only a little discipline to fix, and that I suspect lives in every codebase where the same business rule has to exist in two places.
A supplement claim has a direction. It increases an outcome, decreases it, has no effect, or is unclear. To turn the free-text direction from a paper into something we can count, we bucket it: does this string mean increase, decrease, or neutral? The bucketing is a keyword match — "increased," "raised," "improved," "higher" map to increase, and so on.
The problem is that this bucketing has to happen in two layers of the stack. The precomputed consensus counts are built in SQL, so SQL has to know the keyword lists. The live page also derives some counts in TypeScript, so TypeScript has to know the same keyword lists. Two implementations of the same rule, in two languages, maintained by the same person who is definitely going to keep them in sync. Right.
For a while, they were in sync. Then a new direction synonym showed up in the data — some phrasing we had not bucketed yet — and it got added to the TypeScript list. It did not get added to the SQL list, because the SQL list was in a migration file three directories away and nobody was looking at it. The two implementations were now subtly different. One bucketed the new phrasing as a direction. The other treated it as ambiguous.
The counts diverged, exactly as in the earlier story, for the same reason: two correct paths that no longer agreed. A claim that the live page counted as an increase was, in the precompute, not counted as anything directional at all. Same data, different verdict, no alarm. It took weeks to notice, because each side looked right in isolation.
This is the failure mode of any rule that lives in more than one place. They start identical. They drift. The drift is silent. The drift is small. The drift compounds.
The fix is the boring, correct thing: one source of truth.
We moved the keyword lists into a single SQL function — direction_category(text) — that takes a direction string and returns its bucket. The precompute calls it. There is no separate list in the precompute anymore. The TypeScript side, which cannot call SQL directly, holds a mirror of the function's logic, but it is now a documented, contractual mirror: a comment says "this must match the SQL function," and any change to one is a change that has to be made to both, visibly.
It is not a perfect single source of truth — the TypeScript still has its own copy, because it runs on the client. But the relationship is now explicit instead of accidental. There is one canonical definition, one place a new synonym has to be added first, and a clear contract that the other side follows. The drift became something you have to actively choose to introduce, instead of something that happens to you while you are looking at something else.
This is the universal version: if a rule must exist in two places, make one of them the definition and the other an explicitly-tracked copy. Never let two implementations of the same rule both be "the rule." One is the rule. The other is a reflection, labeled as such, with a note saying what it reflects.
The alternative is two rules that agree today and disagree the day after the next person edits one of them. You will not catch it. The system will look fine. The counts will be wrong. And you will spend a week learning, again, that duplication is not a convenience. It is a bug that has not happened yet.