Skip to content
Crixaa
← All posts

When the same fact is written down three times

The Crixaa team4 min read

Every template in Crixaa has a category — Legal, Finance, HR, Business. That one fact was written down in three places, which was survivable, and one of them worked it out by guessing, which was not.

Three copies, three shapes

Where it lived:

WhereHow it was expressedKept current by
Frontend catalogueA category field on the entryWhoever added the template
Marketing pagecategory: in the page's frontmatterWhoever wrote the page
BackendA function that inferred it from the slugNobody — it was automatic

The third row was why the design felt fine: two hand-maintained copies and one that maintained itself. That is the trap. A copy that never asks you to update it is a copy that never tells you it is wrong.

The function, reduced, but this is the shape:

type Category = 'Legal' | 'Finance' | 'HR' | 'Business'

function inferCategory(slug: string): Category {
  if (slug.includes('invoice') || slug.includes('gst')) return 'Finance'
  if (slug.includes('offer') || slug.includes('appointment')) return 'HR'
  if (slug.includes('nda') || slug.includes('agreement')) return 'Legal'
  return 'Business'
}

Every line except the last is a defensible heuristic reporting what it recognised. The last line reports something it did not recognise as something it did.

The signature says it too. (slug: string) => Category promises this function always knows the answer. The honest return type is Category | null, and writing that forces every caller to decide what happens when it does not — the conversation the fallback exists to avoid.

What it actually broke

A vendor payment advice — a UTR, an IFSC, a rupee amount — was Finance in the catalogue, Finance on its marketing page and Business on the server, because its slug contained none of the matcher's words.

Nothing errored. No exception, no 500, no warning line, no failing test. Each layer was internally consistent and confident. The only symptom was that filtering by Finance did not show a template that was, in two places out of three, Finance.

The debugging cost is the real damage. You check the catalogue: correct. The page: correct. You conclude the data is fine and start reading the filter. The wrong value came from two layers away, out of the component nobody thought of as data.

A fallback that guesses is worse than one that fails

A fallback is fine when it is honest. null, a thrown error, a logged warning — each preserves the distinction between "the answer is X" and "I could not work it out". The moment it returns something indistinguishable from a real answer, you have not handled the unknown case. You have deleted the evidence of it.

The damage is not confined to the one wrong value. Once that fallback exists, every answer the function gives is untrustworthy, because from the outside you cannot tell which were recognised and which were Business by default. Nothing to count, nothing to grep. The rule worth carrying: if a default would be indistinguishable from a correct answer, do not default.

Pick one place and have the rest read it

The fix is unexciting, which is the point: choose a source of truth and have the others read it instead of restating it.

We picked the catalogue entry, because that is where a template is defined and whoever adds one knows its category. The marketing page and the API derive from it. The inference function is gone.

Where reading directly is impossible — different runtime, different repository — generate rather than retype. A build step emits a JSON map from the catalogue and the other side consumes it. Still a copy, but one a single command can recreate, which is the difference between a copy and a fork.

When the copies live in different repositories

Inside one repository a shared union type at least makes a typo a compile error. Across a boundary you get none of that: each side type-checks against its own copy, and no compiler anywhere sees both. They agree only because people have been careful, and people are careful right up until a release week.

So the check has to be explicit, and it has to run in CI on both sides:

// scripts/check-categories.ts
import { catalogue } from '../src/data/catalogue'
import categories from '../generated/categories.json'

const drift = catalogue.filter((t) => categories[t.slug] !== t.category)

for (const t of drift) {
  console.error(`${t.slug}: expected ${t.category}, got ${categories[t.slug]}`)
}

if (drift.length > 0) process.exit(1)

A missing slug fails too. A template present on one side and absent on the other is drift, and treating it as "nothing to compare" reintroduces the original bug in a new place. Run it in the pipeline that publishes, not only in a pre-commit hook — hooks are skippable, and the day it matters is the day someone is in a hurry.

The short version

Count the places a fact is written down. If the answer is more than one, pick the source and make the others read or generate from it.

Then look at anything that derives a fact rather than being told it, and find the branch where it does not know. If that branch returns a plausible value, it is not a fallback. It is a bug designed not to report itself.

Try it on your own document

Design a template in the browser and generate a real PDF — free, no card.