Alphabetical ordering quietly starved four sites of every content slot
listSiteConfig returns sites ORDER BY site, and commissionEmptySlots drained each site's entire empty-slot queue before moving to the next, spending one global per-cycle budget. The alphabetical head
| Repository | claude-editor |
|---|---|
| Detected | 2026-07-23 |
| Fixed | 2026-07-30 |
| Commits | 23ca593 |
Ten commissions a cycle, every cycle, all going to a parked product with no revenue. That's the bit that still gets me. Not that the bug existed, bugs always exist, but that it was this consistent. Not occasional, not flaky, not "happens under load." Every single cycle, same site, same result. That kind of consistency should have been the first clue and instead it just looked like... normal operation, I guess. Anyway.
Symptom
The commission engine runs on a fixed budget per cycle, cap_per_cycle of 10. On the day this got fixed, the live backlog looked like this: marketframe, which is a parked product with no revenue, took all 10 commissions. Paws, which is the site with the largest audience, took zero. Reviewnudge, the only site that has ever actually taken money, also took zero. Ukspend and scriptgrain, zero as well.
So the site nobody cares about was hoovering up the entire budget, and the site that pays the bills got nothing. Every cycle. That's not a rare edge case, that's the steady state.
What we thought it was
First instinct, honestly, was starvation somewhere downstream, like maybe reviewnudge's queue was empty and there was just nothing to commission. That's believable! If a site genuinely has no pending slots, of course it gets zero, that's correct behaviour, not a bug. So the early read was "nothing wrong here, reviewnudge just doesn't have anything queued right now."
Second theory, once someone noticed reviewnudge definitely did have a backlog, was a priority or weighting issue. Maybe marketframe had been assigned some kind of elevated priority by mistake, some leftover config flag from when it launched. That felt right too, because it's the kind of thing that happens: a site gets special treatment during onboarding and nobody remembers to remove it later. Made sense. Fit the pattern of bugs we'd seen before.
Neither of those was it. The actual mechanism was much dumber, and much harder to spot precisely because it didn't look like a bug at all, it looked like the code doing exactly what it was told.
Root cause
listSiteConfig, in src/db.ts:70, returns the list of sites ordered alphabetically by site name. That's it. That's the whole seed of the problem. It's a completely reasonable thing for a config lookup to do, alphabetical ordering is the default, nobody thinks twice about it.
The commission loop, in src/commission.ts:86 at the point just before the fix, iterated over that ordered list and, for each site, drained its entire empty-slot queue before moving on to the next site. And the budget being drained against was global, shared across the whole cycle, not per site.
So picture the loop: it starts at the alphabetical head of the site list, which happens to be marketframe. It asks, how many empty slots does marketframe have. It fills them, one by one, decrementing the shared budget each time. It doesn't move to the next site until marketframe's queue is exhausted or the budget hits zero. With cap_per_cycle set to 10, and marketframe apparently always having 10 or more empty slots to fill, the loop spent the entire budget on site one and never got to site two.
Paws and reviewnudge never had a chance. They're not near the front of an alphabetical list starting with M. The bug wasn't about their traffic or their revenue or anything about them specifically, it was purely about where their name sat in the sort order. Reviewnudge, the one site making money, was structurally last in line, every time.
The fix
The change, landed in commit 23ca593, restructures the allocation so the budget is distributed across sites rather than consumed sequentially by whichever one comes first. Instead of draining one site's queue completely before touching the next, the loop now spreads the available budget across all sites with pending slots in a cycle.
You can see the effect directly in the before and after. Before: paws 0, ukspend 0, marketframe 10, reviewnudge 0, scriptgrain 0. After: paws 2, ukspend 2, marketframe 2, reviewnudge 2, scriptgrain 2. Same cap, same 10 total, just actually shared across the five sites instead of monopolised by one.
Detected on 2026-07-23, fixed 2026-07-30. About a week between spotting it and shipping the correction, which, given what the fix touches, feels about right, you want to be careful rewriting an allocation loop rather than fast.
Why it happened
Here's the thing that actually interests me. Nobody wrote "give marketframe priority" anywhere. Nobody decided reviewnudge should starve. The bug wasn't a decision, it was an absence of one. Two unrelated, individually correct pieces of code met each other and produced a result nobody chose.
listSiteConfig ordering sites alphabetically is fine, in isolation. It's a config query, order barely matters for most callers. And a per-site drain loop with a shared budget is a perfectly ordinary way to write a commission allocator, if you're picturing a world where every site's queue is small relative to the cap. The bug only exists at the intersection: one function's default sort order becoming, silently, an allocation priority for a completely different function that was never told it was consuming an ordered list in a meaningful way.
That's the kind of thing code review doesn't catch, because reviewing src/commission.ts:86 in isolation, the logic reads fine. Loop over sites, drain queue, respect budget. Nothing there looks wrong. You'd have to be reviewing it with src/db.ts:70's ordering behaviour actively in mind, and asking "what happens if this list has a stable, non-random order," which is not a question people naturally ask about a function called something like listSiteConfig.
How to avoid it
If you've got a shared budget being spent across a collection of things, and that collection comes from anywhere with an implicit order, alphabetical, insertion order, whatever, treat that order as load-bearing until proven otherwise. Ask, explicitly, what happens if this list never shuffles. Not "could" it produce uneven results, but what does the steady state look like if the order is genuinely fixed forever.
The practical fix is usually cheap: allocate in a round or in proportional slices rather than sequentially draining, so no single position in a list can absorb an entire cycle's budget. But you only reach for that fix if you've noticed the coupling exists at all, and the coupling is invisible from either function on its own. It only shows up when you trace what actually gets iterated, in what order, against a shared and finite resource.
Worth checking, if you've got anything similar: any loop spending a shared cap over an ordered collection. It's a small thing to check and an easy one to miss, right up until the site with the actual revenue is quietly getting nothing, every single cycle, and nobody chose that either.
Every commit, file reference and date above is checked
against this incident's evidence record before publication. Evidence:
editor-commission-budget-starvation.