Skip to content
8 min read

A paywall that is only a parameter name

The call site passes a flag that reads like an entitlement check. The callee binds it and never looks at it again. Nothing was bypassed, because nothing was ever there — and the tool that could have said so did say so, at a severity nobody gates on.

Vyacheslav Pankratov· Fullstack Developer
Cover art for “A paywall that is only a parameter name”
On this page

TL;DR: An option named like an entitlement check, destructured and never read in the function that receives it. Nothing was bypassed, because nothing was ever enforced — the name was the only implementation. Across a pinned corpus of 18,956 files we found 72 such bindings and zero of them silenced by a lint suppression, which kills the obvious explanation: nobody had to silence anything. The tool reports it at a severity nobody gates on.

The call site was clear about what it wanted. It passed an options object with a key that read like a gate — the kind of name you do not have to explain in review, because everyone already knows what it means.

The callee destructured it into its parameter list, next to the two options it did use, and then never mentioned it again. Not in a branch, not in a guard, not in a log line. The value arrived, was given a name, and went nowhere.

export function handler(req, res, { requireAuth, logger }) {
  logger.info(req.url)
  res.end(render(req))
}

Nobody bypassed the check. There was no check. There was a parameter, and the parameter had a good name, and for as long as anybody had been reading this function the name had been doing the work of the code.

How the decision got out of the function in the first place

This did not start as carelessness, and the structural cause is worth more than the bug.

The feed under it is a single-copy fan-out: one payload is rendered once and delivered to every subscriber. That is what makes it fast and it is why it was chosen. It also means there is no seam for per-recipient policy — no point in the pipeline where the system holds one message and one recipient at the same time and could decide something about the pair.

When a per-recipient decision is nevertheless required, it has to go somewhere, and with the middle unavailable it migrates outward in both directions at once:

Where it went What it looks like there What enforces it
Outward into the payload a field on the message saying how much of itself to show the client, which is asked to honour it
Outward into the call site an options key on the handler that receives the message nothing, as it turned out

Neither destination is a mistake anybody made. They are the two places left when the middle cannot hold the decision, and both were chosen by people who could see the constraint.

  1. One payload, rendered oncefanned out to every subscriber
  2. No seam in the middleno message-and-recipient pair
  • Outward into the payloadenforced by the client
  • Outward into the call siteenforced by nothing
One payload, rendered once, fanned out to everyone. No point in the pipeline holds one message and one recipient together — so a per-recipient decision has nowhere to be, and goes outward twice.

Why didn't the linter stop this?

It did report it. That is the part we had wrong, and it took a corpus to find out.

The obvious story — the one this article was going to tell — is that the unused-variable rule flagged the binding and somebody silenced it on that line, so the tool that found the problem was told to be quiet instead of the gate being written. Our own instance did carry a suppression, so the story fit.

Then we ran an AST pass over a pinned dependency closure to see how common the shape is: every package the lockfile resolves, no hand-picking, 18,956 files parsed after the minified ones were excluded by a stated rule.

functions taking an object-destructured parameter 3,299
names those patterns bind 9,046
never referenced in the body 72 (0.80%)
of those, carrying a lint suppression 0

Zero. Not a small number — none. Nobody is silencing anything, which means the suppression is not what lets this survive and looking for one will find nothing.

What is happening instead is duller and much easier to live with. Running our own configuration over the handler above:

1:37  warning  'requireAuth' is defined but never used  @typescript-eslint/no-unused-vars

A warning. It does not fail the build, does not block the commit, and arrives in the same stream as everything else a lint run says on a Tuesday. The rule worked perfectly and told us, at a volume calibrated for things that do not matter — and severity is not the rule's decision, it is the one line of configuration nobody revisits after the day it is written.

Ask what severity, not who disabled it

Zero of the seventy-two unread bindings we found across a pinned corpus carried a lint suppression. Nobody is silencing anything — the rule reports it as a warning, which does not fail a build. Before hunting for who turned the rule off, check what severity it runs at in the configuration CI actually uses.

The five-minute check

This is worth doing on your own code before you finish reading, because it takes about that long and the interesting result is not the one you expect.

  1. Find the shape, not the suppression. Grep every handler, middleware factory and callback for a parameter list containing {, then read each bound name and ask whether the body mentions it. Your editor's "find all references" is faster than grep for this, one name at a time.
  2. Sort what you find by name. Most unread bindings are debris — a renamed field, a copied signature — and they are boring. You are looking for one whose name asserts a policy: anything beginning allow, require, can, is, skip, only, or naming a tier or a role.
  3. Then check the severity, not the rule. Ask whether your unused-variable rule is a warning or an error in the configuration that actually runs in CI. If it is a warning, you now know how a name like that survives a code review by people who were paying attention.

The third step is the one that generalises. The first two find this instance; the third finds the next one.

What the corpus can and cannot say

It is one application's dependency closure — pinned by lockfile so anybody can reconstruct it, and published as exactly that. It leans heavily on bundlers, transpilers and build tooling, and thinly on the server middleware where an options key named like a gate is most at home. So 0.80% is a lower bound on a population that does not contain many of the shapes we were hunting, and the three gate-shaped hits it did surface are printed with file and line in the results precisely so the name heuristic can be argued with rather than trusted. Two of the three are plainly ordinary booleans.

What it does establish is negative and solid: whatever keeps this pattern alive, it is not a suppression comment. One clean refutation is worth more than a percentage here, because it removes the check most people would have run first.

FAQ

Isn't the real problem that the client is trusted to honour a field? That is a consequence of the same constraint, and it is a different article. The reason both exist is that the middle of a single-copy fan-out has nowhere to put a per-recipient decision, so it went outward twice. Treating either destination as the root cause leads to advice that does not survive contact with the reason the architecture was chosen.

Would making the lint an error have prevented it? It would have prevented this instance, and it is cheap, and you should probably do it. It would not have prevented the decision from migrating — the option would have been read and ignored, or implemented as a no-op, and either of those passes every tool you own.

How do we get a seam for per-recipient policy without giving up the fan-out? By deciding what the fan-out is allowed to carry. If a message can exist in two forms, render two messages and fan each out to its own set; the cost is a second render and a subscription split, and the benefit is that the policy is now expressed as which set you are in rather than as a flag travelling with the payload. That is a real design change with a real cost, which is why it did not happen by accident.

Is an unused parameter always a bug? No, and most of the 72 we found are not. A signature that has to match an interface will bind things it does not use, and that is fine. The name is what turns debris into a defect: a binding called unused is honest, and one called requireAuth is a claim.

The three things worth taking away

A parameter name is not an implementation, and it is the only part a reader checks. In review, a well-named option is evidence that somebody thought about the problem. It is not evidence that anything happens.

The tool was not silenced; it was not listened to. Zero of seventy-two survivors carried a suppression. Before you go looking for who disabled the rule, check what severity it runs at — that is where the answer usually is, and it is a one-line change.

And the shape will come back, because the constraint is still there. Single-copy fan-out has no seam for per-recipient policy. Delete this flag and the next per-recipient decision faces the same empty middle and goes outward again — into the payload, into the call site, or into a name. The choice is not whether the decision leaves; it is whether you decide where it lands.

Where the fan-out carries market data rather than messages, the failures that sit next to this one are on market feeds that reconnect without their symbols.

If the five-minute check turned up a binding nobody reads, and the argument about where the decision belongs is now the hard part, that is the kind of thing we come in for.

Was this helpful?

// Build it

Running this in production?

A fan-out breaks where nobody is watching — a replica count that turns out to be a correctness invariant, a handler that deletes its own replacement, a retry hint that decides your loss. Tell us what the consumers are missing; we reply within a day with a concrete next step.