Dark terminal panel titled EVERY LINT RULE IS A SCAR, showing four custom lint rule codes and the failures they name
The gate, mid-run.

Every Lint Rule Is a Scar

We write architecture documents. They are good documents. They did not stop a single one of the failures below.

The problem with a document is that it is addressed to someone who is already reading it. Nobody reaches for the conventions page before adding a resize call to a module they are already inside; they reach for the import that is closest to hand. The document was never wrong. It was just somewhere else at the moment the decision got made.

So we moved the rules to where the decision happens. The backend carries a custom AST linter with thirty-five numbered rules and eight named ones, run on every push and in CI, alongside ruff, mypy, import-linter, bandit, vulture and a complexity ceiling. A rule is not a style preference. Each one is the shape of a specific failure, written down in the one place that can refuse it.

This is about what we learned writing them — and, at the end, about the day we discovered the register of rules had itself gone quietly wrong.

The rule that exists because a photograph came out sideways

Two modules needed thumbnails. They were built months apart, by people who each did the obvious correct thing: open the image, scale it down, save it. Neither author knew the other implementation existed, which is the normal condition of working in a large codebase.

The two had drifted on both axes that matter. One encoded JPEG at quality 85, the other at 80 — a difference nobody would ever notice. And one applied the EXIF Orientation tag before resizing. The other did not.

That second difference is not cosmetic. A phone held in portrait does not store portrait pixels. It stores landscape pixels plus a tag saying rotate me. Every viewer honours the tag, so the original always renders upright. A re-encode that drops the tag without applying it comes out sideways.

So the same photograph was upright in one surface and rotated ninety degrees in another, on the majority of real phone photos. Nothing raised. Nothing logged. No test failed, because each module's tests asserted only that some thumbnail had been produced, and both had produced one.

ASCII diagram: one phone photo fed through two thumbnail modules; module A applies the EXIF tag and renders upright, module B drops it and renders sideways with no error
The duplication was never the cost. The cost was a correctness divergence in the one case neither author considered — visible only to a user, and only on their own photo.

The fix was one shared function. The rule is what keeps it one: image downscaling lives in a single core file, and no scaling call on an image object is permitted anywhere else in the repository. Reading a dimension is not downscaling and stays allowed — the rule opens a header, it does not touch pixels.

That distinction is the whole craft of writing these. A rule that also bans the legitimate neighbouring operation gets suppressed, and a suppressed rule is a comment.

What a rule actually is

Four artefacts, and the rule is not real until all four exist.

The checker walks the syntax tree, not the text. This matters more than it sounds. One of our rules bans arming a per-subject generation job with any recurrence other than once. The dangerous thing is not a word — it is an argument in a call, and a text search for it would catch documentation, tests and the word in a comment while missing the case where the value arrives as a symbol. So the checker looks for a specific constructor, reads its keywords, and fires only on the real shape.

The message states the failure, not the rule. Not use the shared helperper-subject generation is armed by engagement, one occurrence per answered day; a recurring schedule silently bills every subject that exists, every night. The person reading that message at 6pm has no context. The message is the context.

The escape hatch is per-line, per-rule, and must carry a reason: a suppression carrying no written reason is itself a violation of rule one. We also ban the blanket forms outright — file-level lint disables, whole-file type ignores, bare ignores with no code. You can always get past a rule. You cannot get past it silently.

The register entry is the part we got wrong, and we will come back to it.

ASCII diagram of the four artefacts of a lint rule: checker, message, doc row and register entry
Three of the four are obvious. The fourth is the one that fails, because nothing about its absence is visible.

Three more shapes of silent

The thumbnail rule protects correctness. The others protect things we did not initially think of as lintable at all.

Isolation — a write that landed in the wrong account

A hardware pairing endpoint wrote the index key that identifies a device's owner, unconditionally, from the router. Two accounts could each hold a live row for one device serial. The ingest worker resolved that serial with a limit of one row — and deterministically got the oldest. One household's device readings were written into a different household's partition.

Ownership is now decided by exactly one conditional write, and the rule keeps every other file in the module away from that key. Writing the key is claiming the device, so only the claim path may write it.

Cost — one word, and the bill stops tracking usage

A per-subject generation job costs one model call per slot per armed subject. What bounds that cost is that arming is engagement-driven: answering yesterday's question schedules exactly one occurrence for tomorrow. The obvious-looking fix for a subject who stopped answering is to make the schedule recurring — and that one word converts a cost that tracks real usage into a nightly fan-out over every subject ever created, growing with the user table instead of with engagement.

Nothing fails. The content keeps appearing, correctly. Only the invoice notices.

Dead seam — fifteen call sites, a passing test, and zero events

Our product-analytics seam requires every event to be declared in its owning module. The service drops anything it cannot find a declaration for, and warns. That warning lives in a subsystem whose designed state with no key configured is "no events at all" — which is precisely how the seam sat dead with around fifteen call sites, a green test and a documentation page, having never delivered a single event.

The declaration is also the privacy boundary, not just an index: a declaration names the properties an event may carry and the service drops the rest. An undeclared event is the one path by which an unreviewed property could reach a third-party processor.

Three different subsystems; one shape. In none of them does anything return an error. That is not a coincidence — it is the selection criterion. A failure that raises does not need a lint rule, because it has already told you.

Why not just review it

We did review it. Every one of these passed review.

Review catches the first instance. It cannot catch the second, because the second arrives months later, in a different module, written by someone who never read the first review and has no reason to suspect the code they are about to write already exists. The reviewer of the second thumbnailer was looking at a correct, well-tested, self-contained resize function. It was correct. It was the second one that was wrong, and "second" is not a property visible in a diff.

ASCII table comparing review, tests, the document and a lint rule on whether each sees the first and second instance of a duplicated implementation
A rule is not a smarter reviewer. It is the only mechanism whose field of view is the whole repository at once, every time.

This is also why we keep the incident in the rule's documentation rather than in a postmortem folder. The rule row does not say "one image downscaler." It spends a paragraph on the orientation tag, the two quality settings, and the fact that both modules' tests passed. Anyone who wants the rule removed has to argue with the failure, not with the preference.

The register that needed a register

A guardrail you can quietly weaken is not a guardrail. So there is one more check, and its entire job is to fail the build if anyone softens the gate: relaxing the lint selection, turning off strict typing, expanding an allowlist beyond what is sanctioned, dropping a tool from the runner, removing the coverage floor, forcing a gate step to report success, detaching the pre-push hook, or shrinking the rule set. It runs as part of the gate it protects, so weakening the gate trips the gate.

It holds a hardcoded list of every rule that must exist.

Three rules were missing from that list.

Two had been documented as second uses of an existing code rather than as codes of their own — and a code carrying two rules is exactly how the third went missing. That third rule's checker had been written. It had been given tests. The tests passed. It was never called by the runner that walks the files.

Which is the same failure we wrote up in The Bugs That Returned 200 as a cost guardrail that was never wired in — returning here, one layer up, inside the mechanism built to make it impossible. The comment in the register now says so, in the code, permanently.

ASCII diagram of the CI gate and the gate-integrity check that runs as a step of the gate it protects
The loop is the point. There is no outer ring — which is why the register is written by hand and reviewed like a schema, not generated.

What we'd tell you to copy

Not our rules. Almost all of them are specific to decisions we made, and a rule copied without its incident is cargo.

The method:

  • Write the rule when you fix the bug, in the same change. Not later. Later, the only person who understood which detail mattered has moved on.
  • Only lint the silent ones. If the failure raises, throws or pages someone, you already have a mechanism. Spend rules on the failures whose entire symptom is a number on an invoice or a sideways photograph.
  • Put the incident in the rule. A rule justified by a principle gets argued away. A rule justified by an outage gets respected.
  • Aim at the shape, not the word. Parse the code. A text match bans the documentation and misses the bug.
  • Make the escape hatch loud and narrow. Per-line, per-rule, reason required. People need to get past your rule sometimes. They should never be able to do it quietly.
  • Register the rules, and remember the register is code too. Ours was wrong for three rules and told us nothing, because a thing that checks for absence is the one thing whose own absence nothing checks.

The architecture document still exists. It is now mostly a place to explain why each rule is there — and every claim it makes about how the system is built is a claim something executable will refuse to let us break.

Read more