cookd shows how much of your rolling five-hour Claude window you have burned. That number is computed three times, in three places, for three reasons.
- On your laptop, in a Node CLI, because only it can read your local transcripts. It folds raw token counters into 15-minute buckets.
- In Postgres, in a view, because the server decides anything that gates access or is shown to other people. An entitlement computed on the phone can be spoofed.
- On the phone, because the app polls every two minutes, and a number that changes every two minutes looks broken. The app keeps the buckets it already fetched and re-derives the total every second between fetches.
None of the three can go. The server cannot read your disk, the phone cannot be trusted, and the server cannot push you a number every second.
The reflex is to extract a shared module. It does not work here. The CLI is a Node package in another repository. The server copy is SQL inside a migration. The app is a React Native bundle built by Metro, which cannot resolve files outside its own directory without configuration that adds more risk than it saves.
So there are three implementations for good, and the only question is how to stop them drifting.
A fixture instead of an abstraction
// app/src/lib/windowFixture.ts
/**
* windowFixture — THE shared window fixture.
*
* One set of numbers, exercised by three independent implementations of the same
* rule. If you change any of them, you must change all three, and a test in each
* will tell you if you forgot:
*
* 1. this file + app/src/__tests__/window-derivation.test.tsx (phone, display)
* 2. supabase/test/window-fixture.test.sql (server, authoritative)
* 3. cookd-companion/test/sync/window-fixture.test.ts (companion, producer)
*/
One set of input buckets, one instant to evaluate them at, one expected total. Three test files in three languages assert the same expectations. Nothing is shared at runtime. The only shared thing is the meaning, and the fixture holds it still.
Built backwards from the bugs
Most fixtures come from realistic data, so the expected value is whatever the code printed the day the test was written. Those tests tell you something changed. They do not tell you what.
This one was built the other way, from the failure modes. The fixture's own comment:
The fixture is built so the arithmetic lands on a round number, which makes a failure legible: the total is exactly 8000 weighted tokens, and it is 8000 because a straddling bucket at each end of the window contributes 10/15 and 5/15 of itself respectively. A regression that drops proration, or counts a partial bucket whole, moves the total off 8000 by a visible amount rather than by rounding noise.
Proration only happens at the window's edges, so both edges are deliberately straddled. The limit is chosen so the percentage is a whole number. The total is 8,000 because the inputs were arranged to make it 8,000. So each bug has a signature:
- Proration dropped: the total jumps by most of a bucket.
- Window boundary off by one: a whole bucket appears or disappears.
- A wrong weight: the total moves by an amount tied to that one counter.
The failure message tells you which bug you have, not just that you have one.
The bucket that must contribute nothing
// 06:45–07:00 — entirely before the window opened. Contributes NOTHING.
// It is here because a real client holds buckets that have aged out since the
// last fetch; silently including them is exactly the decay bug that made a
// stored projection wrong.
{ bucketStart: new Date('2026-07-28T06:45:00.000Z'), input: 10000, output: 10000, … },
That bucket is large and worth zero. It is there because of a bug this system already shipped: a value computed at push time and stored kept counting tokens that should have aged out, so an idle user read as permanently at peak.
It would be tidier to leave it out. Leaving it out would remove the only test that stale buckets are excluded. A case whose expected contribution is zero is often the one encoding a bug someone already shipped.
The copy that is wrong on purpose
// A fourth implementation already exists and already disagrees:
// `cookd-app/demo-usage-sim` is an unweighted fork used only for the marketing
// simulator. It is deliberately NOT reconciled here and is marked non-canonical.
// Do not read it as a reference for this rule.
There is a fourth copy. It sums the counters without weights and drives a demo simulator, and reconciling it is not worth the cost. The dangerous version of this is the one nobody mentions, where a future engineer finds the simplest-looking implementation and treats it as the reference. Naming it non-canonical, in the file that defines the canon, costs three lines.
The other kind of drift: a field one side has never heard of
A fixture pins behaviour that the implementations share. It does nothing about a field one side writes and the other does not know exists.
We found one of those in a July audit. Two Edge Functions validate and store JSON blobs of user
statistics, and the app declares TypeScript types for the same blobs. The server's validator passed
through a field called topSkills. The app's Wrapped type did not declare it. Nothing connected
the two, so the field was written to the database and ignored by every consumer, visible only as a
statistic that never appeared.
The fix added the field, and then made the next one a compile error:
// app/src/lib/contractChecks.ts
import type { Wrapped, Tonight } from "./data";
import type { REQUIRED_NUM_FIELDS, … } from "../../../supabase/functions/wrapped-sync/pure";
/** Passes iff every name in Fields is a key of T. */
type AssertKeysOf<Fields extends string, T> = Fields extends keyof T ? true : never;
const _assertWrappedCoversValidator: AssertKeysOf<WrappedValidatorFields, Wrapped> = true;
void _assertWrappedCoversValidator;
Each validator declares its accepted field names as a const array, which TypeScript reads as a
literal union. The app imports those types only, and asserts the union fits inside keyof its
own type. Add a field to a server validator without adding it to the app type, and npm run typecheck fails at that field.
The plan recorded a spike before adopting it, checking three things:
- It is free at runtime.
import typeis erased before the bundler runs. Metro never sees the path, so no Deno code can reach a React Native build and there is no bundle cost. - The type checker and Jest can both resolve the relative path into the functions directory. Only the shipped bundle is restricted, and type-only imports never get there.
- It catches drift. A throwaway fake field added to a validator made the typecheck fail,
pointing at
contractChecks.ts, before being reverted.
A schema-validation library was considered and rejected. Every validator in those files is hand-rolled, the codebase had no such dependency, and adding one would have been bigger than the bug. The whole guard is 42 lines.
What this gives up
- Three hand edits per rule change. The fixture tells you that you forgot one. It does not make the change. A new weighting rule is a three-repository, three-language change, and no shared runtime is possible under this build topology.
- Equivalent, not identical. Postgres
numericand JavaScriptnumberround differently at the edges. The fixture lands on round values partly to avoid false failures, which means it does not exercise the edges where the two really diverge. - One direction only. The type check asserts the app covers every field a validator accepts. A field the app declares and no validator writes passes silently. That direction is less dangerous, and was left out on purpose.
- A copy that is known to be wrong. Marked and documented, and still able to confuse whoever finds it first.
What we would do differently
- Add a fixture case at the numeric edges. Round totals keep the three implementations from
disagreeing on noise. One deliberately awkward case, with a stated tolerance, would pin the place
where
numericandnumberactually part ways. - Make the type check two-way. The reverse assertion is a second line. Dead fields on the app side are cheap to catch now and confusing to find later.
- Point the demo simulator at the fixture too. It does not need to match. A test that asserts it differs by the expected amount would stop it drifting into a third meaning.
Pin what you cannot share
When the build topology forbids a shared module, the fallback is not "be careful". It is a fixture that fails in every copy at once, with inputs chosen so each failure names its bug.
And types are erased, so a type-only import can cross a build boundary the bundler refuses. That turned a class of silent contract drift into a failing typecheck, for zero bytes of bundle.