We sell access to one assessment provider at a time. Providers vary a lot in size: our Arctic Shores suite is 14 games, TestGorilla is 17, and the College of Policing is two, because the real sift there is exactly two automatically scored exercises.
Charging one price for both ends of that range felt wrong, so for months we did not. A provider with five or fewer playable games cost £10 instead of £15, and its bundle with scores and feedback cost £15 instead of £20. As of yesterday every provider costs the same, and the removal is 81 files, 94 insertions and 770 deletions.
The rule was derived, which was the good part and the problem
The tier was never a column or a hand-kept list. It was computed from the library:
function isSmallProvider(provider?: AssessmentProvider | string | null): boolean { if (!provider) return false; const count = playableGameCount(provider as AssessmentProvider); return count > 0 && count <= SMALL_PROVIDER_MAX_GAMES; } The doc comment above it argued the case well: derived from the game library rather than a hand-kept table, so adding a game to a short suite moves its price on its own and no second list can drift out of sync. An unrecognised provider is never small, which is the safe direction, because a bad id can then never buy a large suite at the small-suite price. Both the unlock and the bundle read the same predicate, so the two prices could not end up disagreeing about which tier a provider was on.
All of that is true, and I would write it again. The problem is upstream of the implementation: a price that is a function of a game count is a price that changes when engineering ships a game. Build the sixth exercise for a provider and its price silently goes up by £5, for everybody, including people who were looking at the page an hour ago. There is no release note for that, no migration, and no moment where somebody decides it.
With 30 of 65 providers on the reduced tier, one more game on any of them was a price change.
Every sentence became a range
The second cost showed up in the copy, which is where I would look first next time. Two prices mean you cannot state one, so the library grew a derived minimum for marketing surfaces:
/** * The cheapest provider unlock on offer in a price region, for "from £X" copy * on surfaces that cannot name a single provider. Derived so the claim cannot * go stale. */ export function getMinProviderPrice(region: PriceRegion = STANDARD_PRICE_REGION): number { return Math.min(...ALL_PROVIDERS.map((provider) => getProviderPrice(provider, region))); } And then every sentence about price was hedged. Our comparison pages carried lines like "One provider costs £15 at most" and "you pay once for the provider your employer uses, from £10". The pricing FAQ needed a subordinate clause and an explicit {' '} with a comment explaining that a JSX text run holding an HTML entity loses its leading space when compiled, which had once rendered as "5games".
"At most" and "from" are the vocabulary of a page that does not want to tell you the price. Here is the same FAQ answer as a reader sees it, before and after (the figures are interpolated from the constants, so the source is a little noisier than this):
- Provider Access unlocks every game from one provider of your choice, with - unlimited plays for life. It is £15, or £10 for a provider with 5 games or - fewer. It removes the free-play limit... + Provider Access unlocks every game from one provider of your choice, with + unlimited plays for life, for £15 per provider. It removes the free-play + limit... The signatures survived on purpose
Both price functions still take a provider they no longer read:
/** * List price in pence to unlock one provider, in the given price region. * * Every provider costs the same. The provider is still taken so call sites read * as "the price of this provider" and a per-provider price could return here * without touching them. */ export function getProviderPrice( _provider?: AssessmentProvider | string | null, region: PriceRegion = STANDARD_PRICE_REGION ): number { return pricesFor(region).provider; } The underscore is doing documentation work. An unused parameter is usually a smell, and keeping one with a comment that says why is the difference between a decision and an oversight. If we ever price a provider individually, the change is one function body and zero call sites, which is worth more than the tidiness of deleting an argument from the 17 places that pass it across 11 files.
67 tests went, and one of them was load-bearing for something else
The commit deletes 67 it blocks, 55 of which have the word "tier" in the name. Most are per-provider: 53 of the 58 test files it touches are provider registration tests, each of which asserted its own provider's tier. The names are all variations on a theme, which is what happens when parallel workers write the same test 53 times: "lands on the larger price tier, derived from the playable count", "lands on the small tier from four playable tests, derived rather than set", "derives the full price from nine playable games rather than hand-setting it".
Two of the deleted cases in the shared pricing test are worth keeping in mind for any derived rule:
it('puts a provider on each side of the threshold, so the tiering is live', () => { // A guard against the rule becoming vacuous: if every provider drifted to // one side, the two-tier pricing would be dead code and this would fail. const prices = ALL_PROVIDERS.map((p) => getProviderPrice(p)); expect(prices).toContain(SMALL_PROVIDER_TOKEN_PRICE); expect(prices).toContain(PROVIDER_TOKEN_PRICE); }); That is a vacuity test, and it is the one test a branch-based rule actually needs: not "does the rule work" but "is the rule still doing anything". It has been replaced by its mirror image, which guards the new invariant instead:
it('charges every provider the same price, whatever the size of its suite', () => { // Every provider in one pass, so a per-provider price cannot creep back in // for one of them unnoticed. for (const provider of ALL_PROVIDERS) { expect(getProviderPrice(provider), provider).toBe(PROVIDER_TOKEN_PRICE); } }); The accident is in the per-provider files. Here is the College of Policing one that went:
it('lands on the small price tier, derived from the playable count', () => { // Two playable tests because the real sift is exactly two automatically // scored exercises. Nothing is padded to reach a tier. const playable = getGamesByProvider('college-of-policing').filter((g) => g.implemented !== false); expect(playable.length).toBe(2); expect(playable.length).toBeLessThanOrEqual(SMALL_PROVIDER_MAX_GAMES); expect(getProviderPrice('college-of-policing')).toBe(SMALL_PROVIDER_TOKEN_PRICE); }); Read the comment. That test was pinning the pricing tier, and it was also pinning something we care about far more than pricing: that the number of exercises we publish for a provider matches what the real process has, and that nobody quietly added a third to clear a threshold. When money depends on a count, somebody eventually has a reason to move the count.
Deleting the tier removed the incentive, which is good, and removed the assertion, which was not the plan. Before the commit, 35 provider registration files asserted a playable count through that filter. After it, one does. The count assertions were a side effect of the pricing assertions, and nothing in the type system or the suite noticed them leaving.
They are going back in on their own terms, as a plain "this provider publishes exactly N playable exercises, and here is the published source for N" check, with no price in it. A rule that two unrelated things depend on is fine right up until you delete one of them.
See it
Prices show in your own currency, so the figures below are the euro ones I get from the Netherlands; from the UK they are £15 and £20.
Open cogniprep.app/games/college-of-policing, which has two playable tests, and then cogniprep.app/games/testgorilla, which has 17. Both pages quote €18.99 and neither says "from". Yesterday the first one said €12.99.
The pricing FAQ on cogniprep.app/pricing now answers with one figure and no subordinate clause, and searching the page for "games or fewer" returns nothing.
The comparison pages are the clearest before and after, because a rival comparison is where a hedge hurts most: cogniprep.app/comparison/cogniprep-vs-jobtestprep says "One provider costs €18.99" where it used to say "costs €18.99 at most", and "you pay €18.99 once" where it used to say "from €12.99".

