September 14, 2026
Two mechanisms that look redundant, and why only one of them was
The setup: one product, two rankings
Kingpin shows the same listings on two surfaces. /feed is a swipeable media feed; /board is
the ladder people actually land on and share. They had two different ranking formulas:
| ranked by | |
|---|---|
/feed |
rankCards() — bid baseline, shifted by crowd votes, eviction at −5 |
/board |
candidateListings() — effectiveCents only: money + 5%/day decay |
So the crowd could dethrone you inside the swipe feed, and only there. On the main page, money still bought a permanent position. The product's central claim — the crowd decides who stays on top — was silently false on the most-visited surface.
Nobody designed this. It's residue: the board is the pre-pivot leaderboard (Phases 0–8), the feed came with the pivot (Phases 9–15), and the old surface was never retired. Architectural drift usually looks like this — not a bad decision, but an old decision nobody re-opened.
The fix was to rank both through the same function. What made it interesting was what surfaced while doing it.
Decision 1: decay looked redundant. It wasn't.
Once the board ranked on votes, a fair question came up: why decay a bid automatically at all? Downvotes already push bad listings down — isn't decay doing the same job twice?
It sounds right. It's wrong, and the reason is worth internalising.
They answer different questions:
- A downvote answers "is this bad?" It requires a human to actively dislike the thing.
- Decay answers "you paid in March — why are you still #1 in September?" It requires nobody.
The gap between those is indifference. A listing that's merely dull collects roughly zero net votes forever. Nobody downvotes a product for being old. So removing decay means: pay once, avoid annoying anyone, hold the top spot permanently — precisely the thing the product promises cannot happen. Indifference is not a downvote, and a system relying on downvotes alone is relying on indifference to do a job it can't do.
There was a second, less obvious cost. Decay is the only reason the escalating refuel price exists — positions must erode for a re-bid to mean anything. Deleting decay would have silently retired a revenue model and left working, tested code permanently unreachable. That consequence was nowhere near the code being discussed.
But the instinct behind the question was still correct. With ~2 listings and effectively no voters, decay was the only force acting on the board, it moved one direction, and the only way to recover position was to pay again. That doesn't read as "the crowd decides." It reads as the platform billing people for standing still — and it punishes founders for the platform's lack of traffic, which isn't their fault.
So the fix wasn't to the mechanism, it was to its conditions: decay stays fully implemented
behind FLAG_DECAY, defaulting off, flipped on deliberately once vote volume makes the crowd
the dominant force.
// lib/scoring.mjs — one gate, honoured by BOTH effectiveCents and bidBaseCents,
// so no surface can disagree with another about what a listing is worth.
export const decayEnabled = (flags = {}) => flags?.decay === true;
Why a manual flag and not an automatic traffic threshold: a threshold that trips on its own would erode every paid position the instant it crossed, with no warning to any founder. A flag someone flips means the moment is chosen, announceable, and reversible. Same end state, no ambush.
The generalisable lesson
Before deleting a mechanism that looks redundant, write down the exact question each mechanism answers. If the questions differ, the redundancy is an illusion — and check what else depends on it, because load-bearing things are rarely labelled.
A useful tell: the mechanisms overlapped in effect (both push a listing down) but not in trigger (dislike vs. time). Overlapping effects are what make two things look like one.
Decision 2: founders don't vote
A founder has a direct financial interest in every rival's rank. Once votes moved paid position on the main board, leaving founders able to vote would have turned the crowd signal into a proxy war between paying competitors — and made the cheapest listing on the platform a ticket to downvote the leader.
Two design calls worth recording:
Scope: all voting, not just others' listings. "Founders can't vote on other people's listings" immediately invites "…so they can vote for themselves?" — and self-voting is the more blatant manipulation of the two. One rule with no edge cases beats a narrower rule that's harder to defend.
This rejection is loud, when every other rejection in that handler is silent. The vote pipeline deliberately fails quietly, because a clear error tells a bot exactly which control it tripped. This case is the opposite: a published policy, not a detection. Returning a fake success to a paying founder would be lying to a customer.
// vote.mjs — 403 with a reason, not the usual silent { applied: false }
if (identity.FounderId) return forbidden('Founders cannot vote. …');
Silence is a security tool, not a default. Use it where disclosure helps an attacker tune. Where the rule is public anyway, silence buys nothing and costs trust.
The limit, stated honestly
The link is per-device, not per-person, so it is a deterrent and a statement of the rule, not an unconditional wall — the same per-IP/per-ASN caps, confidence weighting, and velocity shadow-ban that resist any other bad actor are what actually backs it up.
That distinction matters more than it sounds. A control described as airtight when it isn't produces worse decisions later, because someone downstream stops building the defence that actually does the work.
A small thing that keeps recurring
Wiring FLAG_DECAY meant adding it to the root stack and forwarding it to both nested
stacks. The existing FLAG_STREAK was never forwarded — it only ever used each nested stack's
default. Harmless while the default is what you want; invisible breakage the day someone tries to
flip it.
This is the third time today a parameter has been declared somewhere it couldn't actually be set
from (see entry 001's HostingerFrom). A config value you cannot demonstrate changing the
behaviour of is not configuration — it's a constant with extra steps. Worth testing the flip,
not just the default, whenever a flag is introduced.