Back to blog
·4 min read·Cell Webb

Pricing Copy Drifts Unless It's Generated

Our blog and README contradicted our own pricing page. How KittyLog now generates pricing constants from one YAML file instead of transcribing them.

KittyLog's own blog told prospects that the free tier included 100 AI calls per month. The README said 50. The pricing page said unlimited. All three were public at the same time, until a fix in July 2026, and two of them were wrong about a product whose entire pitch is keeping public records accurate.

It gets slightly worse. The README's tier table advertised a top tier called "Ultra". There is no Ultra. The live pricing page has four tiers: Free, Plus, Pro, Business. The README also listed Pro at $25/mo while the pricing page showed $15 early-bird. A GitHub visitor and a website visitor were reading about two different products with the same name.

Issue #1317 laid all of this out in July 2026, with a table comparing what the README said against what the site said, line by line. Reading your own contradictions in table form is a useful kind of embarrassment.

How the drift happened

The blog post in question is dated December 2024, when the quota was real. The README table described a tier structure that existed at some point. Then the pricing was revamped: quotas removed, generations made unlimited on every tier, "Ultra" renamed to Business, early-bird pricing introduced. The pricing page was updated because the pricing page is where you think pricing lives.

The blog and the README were not updated, because at the moment you change a price, you are thinking about Stripe objects and the pricing component, not about paragraph four of an eighteen-month-old blog post.

That is the whole mechanism. Every sentence of copy that restates a fact from somewhere else is a cache entry, and nothing was invalidating it. The blog cached the 2024 pricing. The README cached a different vintage of it, which is why the two stale surfaces did not even agree with each other. A cache that is never invalidated fails quietly, serving old data to whoever happens to read it, which in our case, per the issue, included search traffic landing on an indexed post that described a worse free tier than the one we actually offer.

Generate what can be generated

The structural half of the fix predates the embarrassment. Issue #1028 pointed out that frontend/src/components/landing/pricing-constants.ts hardcoded repo limits and prices that had to be manually kept in sync with the backend's api/config/tiers.yaml. The first mitigation was a parity test that failed CI when the two files disagreed. That works, but a test that says "these two hand-written copies differ" still leaves a human to reconcile them.

The better answer was to stop having two copies. A build script, frontend/scripts/generate-pricing-constants.mjs, now reads tiers.yaml and emits pricing-constants.generated.ts. It runs as part of pnpm dev and pnpm build, so the file the pricing page renders from is always the compiled output of the backend config. Change a price in the YAML and the marketing site cannot disagree with the API, because the marketing site's numbers are not written by anyone. Transcription errors require transcription.

Prose can't be generated, so it gets a hierarchy

A script cannot rewrite paragraph four of a blog post. Prose claims need a different discipline, and ours is a written source hierarchy: when a claim about pricing appears anywhere public, it resolves against the generated constants file, which resolves against tiers.yaml. The README and older docs are explicitly ranked below that. If a README sentence disagrees with the generated file, the README is wrong, full stop, and the fix goes to the README rather than softening the source to match the drift.

The actual copy fix, commit 6ddaf63d, was small: three lines in the README, three in the old blog post. The point of the hierarchy is that the next person editing copy does not have to remember which surface to trust.

This does not make drift impossible. A blog post from two years ago can still restate a fact that later changes; no build step reads old prose. What changed is that restated facts are now treated as liabilities. New copy states fewer of them, and the ones it must state come from the source files, not from memory of what the pricing was.

The rule

The rule we follow now, written into the pricing-change checklist: a price change is one change, and it updates every surface in that change. The YAML, the regenerated constants, the pricing page, the README, the docs, any social card that names a number. Updating a price in only some of those places just restarts the drift, with the stale copies waiting for whoever reads them next.

If you maintain a product with a pricing page, a README, and a blog, it is worth grepping the other two for your prices today. We failed that check for longer than I want to admit.

Source records

  • Issue #1317, the stale blog and README pricing report.
  • Issue #1028, auto-generating pricing constants from tiers.yaml.
  • Commit 6ddaf63d, the copy fix for #1317.
  • api/config/tiers.yaml and frontend/scripts/generate-pricing-constants.mjs.
  • .claude/skills/marketing-claims-and-pricing/SKILL.md, the source hierarchy.

Ready to automate your changelogs?

Try KittyLog free - no credit card required.

Get Started Free