The check that watches the checks

The check that watches the checks

How architecture rules stop being preferences.

Someone on the team opens a pull request. One line, added to a service class:

@cache
def resolve_plan(code: str) -> Plan:
    ...

It gets approved, and of course it does. It's memoization on a lookup that looks pure, it takes a hot path off the wire, and the whole diff fits in a tweet. It sails through the formatter, the type checker in strict mode, the security scanner, and the test suite — because there is nothing wrong with it as Python.

It is still a bug. Not a style nit, not debt to file away: a correctness bug that only exists above one running copy of the process. The service scales horizontally behind a load balancer. Each replica memoizes on its own. When the underlying record changes, the replicas disagree, and which answer a caller gets depends on which replica the balancer happened to pick. Nothing errors. Nothing logs. No test goes red. There is only a system that is correct most of the time, which is a much worse thing to own than a system that is broken.

The team already knew this. It was written down — a numbered clause in the engineering charter, in a document everybody had read, saying that services must be horizontally scalable by construction. Being written down did not help. "No instance-local state that spans requests" is a fact about the deployment topology, and a cache decorator is a fact about a function, and no reviewer is holding both of those in working memory at five o'clock on a Thursday.

So we stopped writing that clause down and started executing it.


The rules a general-purpose linter can't reach

The codebase in question is a Python modular monolith running on a managed container platform. It carries the usual battery: an aggressive linter with every rule family enabled, a type checker in strict mode, an import-boundary checker, a security scanner, a dead-code detector, a complexity ceiling, a coverage floor.

All of that catches what it catches — the class of mistakes that are wrong in general. Unused imports. Shadowed names. Missing annotations. Real value, and none of it touches the mistakes that actually hurt.

The mistakes that hurt are wrong here. They're wrong because of a decision this particular system made: that tenant identity is derived from the authenticated session and never from the request payload; that every business write passes through one persistence layer so provenance gets stamped; that exactly one component in the system is allowed to own the clock. None of those are expressible in an off-the-shelf linter, for the obvious reason that none of them are true of Python programs in general.

Two classes of wrong. Only the left one is a property of the language.
Two classes of wrong. Only the left one is a property of the language.

So there is a script. It parses every source file into an abstract syntax tree and walks it looking for the specific shapes this architecture forbids. A few dozen rules, each one a sentence from the architecture document that has stopped being a sentence.

Here's the one from the opening, in outline:

def _instance_local_state(tree: ast.Module) -> list[tuple[int, str]]:
    """Flag process-local state that spans requests."""
    found = []
    for node in ast.walk(tree):
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            dec = _cache_decorator(node)
            if dec is not None:
                found.append((
                    dec.lineno,
                    "a cache decorator holds process-local state; keep the value "
                    "immutable and identical on every replica, or move it to a "
                    "shared store",
                ))
    ...

Three shapes, one rule. A cache decorator. A module-level mutable container or lock bound to a non-constant name. A mutable container assigned onto self inside a class that gets instantiated exactly once at import time — the singleton-service buffer, which is the sneakiest of the three, because it reads as perfectly ordinary object state right up until there are two objects in two processes.

The wording of the failure matters as much as the detection. It does not say rule violated. It says what is wrong with this code and what to do instead. A rule that fires with an identifier and nothing else is training its readers to reach for the suppression comment.

The rest of the catalog is the same idea aimed at different invariants:

  • a module may reach another module only through its published interface package — never its service layer, its router, its models, or its persistence code
  • no raw datastore write outside the sanctioned writer layer; every business write goes through the floor that stamps who and when
  • every data-transfer object that crosses a module seam is frozen — a value, not a handle
  • no module reads environment variables directly; configuration arrives through one seam
  • exactly one component emits scheduled events, and no other module is allowed to run its own polling loop
  • one expensive full-table query pattern is banned outright inside the domain layer

Every one of those was a paragraph somebody wrote, and a paragraph somebody else violated.


Precision is the whole game

A rule that is approximately right gets suppressed, and a rule that is suppressed everywhere is worse than no rule at all — now the codebase is decorated with comments asserting compliance that nobody audits.

Take the rule that every non-public route must validate its caller at entry. The naive implementation — does the handler body mention the guard function? — is close to worthless, because this passes it:

@router.get("/things/{thing_id}")
async def get_thing(request: Request, thing_id: str) -> ThingOut:
    if request.query_params.get("strict"):
        principal = require_principal(request)   # present, but conditional
    ...

The guard is there. It just doesn't always run.

So the rule carries an explicit model of which syntax nodes defer execution:

# Constructs whose body does not run on the way in. A guard inside one of these is
# not reached "at entry" — the exact wording the rule uses. It would validate some
# requests and let the rest through carrying whatever the guard chain happened to
# bind. `try` and `with` bodies DO run unconditionally, and are deliberately absent
# from this tuple.
DEFERRED = (
    ast.If, ast.IfExp, ast.For, ast.AsyncFor, ast.While, ast.Match, ast.BoolOp,
    ast.Lambda, ast.FunctionDef, ast.AsyncFunctionDef,
    ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp,
)
A guard only counts if it sits on the path every request takes.
A guard only counts if it sits on the path every request takes.

A try: body executes on the way in, so a guard inside one counts. An if body does not, so it doesn't. Getting that single distinction right is the difference between a rule engineers trust and a rule engineers route around.

The second half of the same rule forbids tenant identity arriving from the request: no handler parameter named for a tenant or an organization, no reaching into the path parameters for one. And the comment explaining the banned-name list is doing as much work as the code:

# Subject identifiers — the id of a row, a document, a device — are deliberately
# absent from this list. They name something *inside* the caller's tenant, which
# the persistence floor then scopes. That is the normal shape of a route, not a
# claim about who you are.

That is the hard part of writing these. The dangerous shape and the perfectly ordinary shape are separated only by intent, and you have to encode where the line falls.

The same discipline shows up as deliberate narrowness. One rule bans a pair of timezone and date-formatting calls — but only inside the payments domain, where a third-party API's date encoding is a wire-format concern that must never be re-derived locally. Everywhere else, rendering a user's local time is a legitimate domain concern with a real timezone attached. The scoping note is written into the rule itself, because a future maintainer's first instinct will be to generalize it:

Deliberately scoped, not global. Other domains legitimately render a person's local time — quiet hours, a day boundary, a digest's date label — and that is a real requirement, not a vendor encoding. Widening this rule would forbid the legitimate use along with the dangerous one.

A global version of that rule would be tidier and would survive about a week.


Every rule is a scar

Read a catalog like this end to end and it stops resembling a style guide. It reads like an incident log with enforcement bolted on.

There is a rule that says exactly one file may write one particular index attribute on a device record. Oddly specific — until you know that the attribute is what a cross-tenant serial-number lookup resolves against, so writing it is the act of claiming the hardware. The pairing endpoint used to write it unconditionally. Two households could each hold a live record for one physical device, and the ingest worker — resolving by serial, taking the first match — deterministically picked the older one and wrote the new owner's data into the previous owner's tenant.

There is a rule that no field named like money may appear on an inbound request model in the commerce layer. The order body used to carry a unit amount, and the server used to believe it. Outbound models are untouched: the server stating a price it computed itself is the entire point.

There is a rule that transcript rows are written inside exactly one method, which takes a required destination argument so the destination cannot be omitted. Before it existed, messages from an urgent-triage flow could land in the ordinary conversation thread, where they were indistinguishable from normal chat afterward and could not be separated back out. The rule's own note explains why it is a lint and not a review checklist item: nothing about that failure is loud — the write succeeds, the wrong transcript simply grows.

There is a rule that one attribute name may appear in exactly one file, because four separate operations enumerate what a post owns — attach, read projection, delete-on-removal, sweep-on-account-erasure — and a shorter list in any one of them is silent. A delete that walked only the images leaves half a gigabyte of video in the bucket with its post gone. An erasure that missed the same field leaves a person's face in storage after they asked to be forgotten.

This is the reframe worth carrying away: an executable rule is the cheapest postmortem action item there is. The usual output of a postmortem is a document, a ticket, and a shared intention to be more careful. All three decay. Thirty lines of tree-walking does not decay, and it re-runs on every push forever — including on the engineer who joins in eighteen months and has never heard of the incident.


Problem one: adopting a rule on a codebase that's already running

This is where most "enforce the architecture with linters" efforts quietly die.

You write the rule. You run it for the first time. You get three hundred violations. Now you have three options: fix all three hundred before merging anything (nobody does this), make the rule advisory (it never becomes real), or shelve the rule (the honest version of option two).

The way out is to make the rule blocking immediately and scope it to new code, with the existing debt enumerated in the open and only ever shrinking.

Adoption as a ratchet: blocking on day one, with the debt enumerated and shrinking.
Adoption as a ratchet: blocking on day one, with the debt enumerated and shrinking.

A rule banning test doubles — integration tests run against real service containers and skip when a dependency is absent, rather than passing against a fake — landed on a codebase that already had fakes in it. So:

# Pre-existing fake/mock usage, grandfathered as MIGRATION DEBT THAT ONLY SHRINKS.
# Never extend this list to unblock new code — new violations are fixed by using
# the real implementation.
MIGRATION_ALLOWLIST = (
    ...
)

A handful of files. Enumerated in the source, with the direction of travel in capital letters. Anyone can see the size of the debt and watch it go down. And the escape hatch is not a mechanism you can invoke — it's a list you are either on or not. Any new file with a mock in it fails the push.

The second adoption mechanism is a warning tier with a promotion path. When the codebase adopted a standard contract for sensor time-series, several of its rules could not be satisfied yet, so they shipped as advisory rules: reported on every run, never gate-failing, each one annotated as promotable to a hard rule once the codebase satisfies it. One of them names the remaining offenders directly, in a comment that is effectively a burndown chart living next to the enforcement — two domains migrated, one to go. When the last one migrates, the final step is deleting a tuple entry and moving the rule into the required list.

The general principle: ratchets, not bans. A rule that can only get stricter is adoptable on day one. A rule that must be perfectly satisfied before you're allowed to switch it on never gets switched on.


Problem two: keeping the gate from eroding

This is the failure mode nobody plans for, and it is the one that actually gets you.

Six months in, a release is blocked. The type checker is unhappy about a third-party stub. The linter is flagging a pattern the team has since decided is fine. One architecture rule is firing on a file that is a genuine exception. It's six in the evening, someone is waiting, and the fix is right there, and it is one line:

- select = ["ALL"]
+ select = ["E", "F", "W", "I"]
  - name: Lint
-   run: make lint
+   run: make lint || true

Nobody is being malicious. Everybody fully intends to put it back. It does not go back, because there is no failing check to remind anyone that it's gone — removing the failing check was the entire point of the change.

The gate erodes silently and asymmetrically. Every emergency loosens it a notch, and nothing on the other side ever tightens it.

Every emergency loosens the gate a notch. Nothing pushes back the other way.
Every emergency loosens the gate a notch. Nothing pushes back the other way.

The counter is about a hundred lines of code with a docstring that reads like a charter:

"""Gate-integrity guardrail — prevents softening or disabling the guardrails.

A guardrail you can quietly weaken is not a guardrail. This check fails the build
if anyone loosens the gate: relaxing the linter's rule selection, turning off
strict type checking, expanding an allowlist beyond what's sanctioned, dropping a
tool from the lint pipeline, removing the coverage floor, adding `|| true` or
`continue-on-error` to a gate step, shrinking the architecture rule set, or
detaching the pre-push hook.

It is itself part of the gate, so weakening the gate trips the gate.
"""

Mechanically it's unglamorous, and that's fine. It parses the project config and asserts that the linter's selection is still the full set and that strict mode is still on. It diffs the security scanner's skip list against a sanctioned set. It reads the task runner script as plain text, slices out the lint block, and checks that every required tool name still appears in it and that the string || true does not. It reads the CI workflow and fails on continue-on-error: true. It checks that the pre-push hook still invokes both lint and test. And it asserts that every architecture rule identifier still appears in the rules source, so deleting a rule is exactly as loud as breaking one.

The move that makes the whole thing work is one line in its own required-tools list:

REQUIRED_LINT_STEPS = [..., "check_gate_integrity"]
...
if "check_gate_integrity" not in lint_block:
    fail("lint pipeline: the gate-integrity check is not wired in")

It requires its own presence in the pipeline it audits.

The cycle that makes it hold: the pipeline runs the check, and the check requires the pipeline to run it.
The cycle that makes it hold: the pipeline runs the check, and the check requires the pipeline to run it.

You cannot disable the check that would notice you disabling checks, because disabling it is one of the things it notices. To weaken the gate you now have to also weaken it in the file named after the thing you are doing — a file whose entire content is a statement of what the team agreed to, in a diff that is impossible to read as anything other than what it is.

And that is the actual goal. Not preventing the change: you should be able to relax a rule that turns out to be wrong. The goal is making the relaxation visible and deliberate rather than invisible and expedient. Tightening the gate requires no ceremony at all. Loosening it requires editing the file that exists to notice you loosening it.


Escape hatches that cost a sentence

Every rule needs an override, or engineers fight the tool instead of using it. But an override that costs nothing gets used reflexively, and then the codebase is full of suppressions nobody can evaluate.

So the price of an override is that you have to say why:

SUPPRESS = re.compile(r"#\s*arch:\s*ignore\s+(?P<rule>[A-Z0-9-]+)\s+--\s+\S")
REASONLESS = re.compile(
    r"#\s*(noqa|type:\s*ignore|nosec|pragma:\s*no cover)\b(?![^\n]*--)"
)

One rule fails any suppression comment that doesn't carry a -- and a reason after it. Another fails the blanket forms outright: file-level linter disables, file-level type-checker disables, a bare suppression with no specific code attached. Suppression is per-line, per-code, with a justification — or it isn't suppression, it's surrender.

The horizontal-scaling rule gets its own dedicated marker, because "this state is local by design" is a real and recurring answer: a process cache that is provably identical on every replica, a development-only fallback, the bootstrap registry itself. Giving that case its own vocabulary keeps it out of the generic-suppression bucket, where it would be indistinguishable from giving up.

The calibration signal is the count. Across the whole tree there are a couple hundred reasoned suppressions of the general-purpose linters — normal, and each one carries an argument. There are single digits' worth of suppressions of the architecture rules. That second number is the one that says the rules are aimed correctly. If it were in the hundreds, the rules would be wrong, and the honest move would be to fix the rules rather than the code.


Two small details that punch above their weight

A duplication kept on purpose. The entry-guard rule needs to know which routes are public, so it holds a verbatim copy of the middleware's public-path allowlist:

PUBLIC_EXACT_PATHS = frozenset({...})
PUBLIC_PATH_PREFIXES = (...)
PUBLIC_PATH_SUFFIXES = (...)

Copy-pasted configuration in two places is normally a defect. Here it is deliberate: the rules script is standalone and must not import the application, because that would drag the entire web framework into the lint step. So a unit test locks the two copies together:

def test_public_allowlist_matches_the_middleware() -> None:
    assert rules.PUBLIC_EXACT_PATHS == middleware.PUBLIC_EXACT
    assert rules.PUBLIC_PATH_PREFIXES == middleware.PUBLIC_PREFIX
    assert rules.PUBLIC_PATH_SUFFIXES == middleware.PUBLIC_SUFFIX

Now adding an unauthenticated route means touching two files, and having a test tell you about it if you touch only one. The redundancy is the control: "this endpoint skips authentication" becomes a thing you cannot do absent-mindedly.

Aggregate, don't fail fast. The lint task and the pre-push hook run every phase even after one has failed, then block with the complete list:

step() { local n="$1"; shift; echo "==> $n"; if ! "$@"; then FAILED="$FAILED $n"; fi; }
step lint      ...
step typecheck ...
step arch      ...
if [ -n "$FAILED" ]; then echo "FAILED:$FAILED"; exit 1; fi

A fail-fast gate teaches you to fix one thing and re-run, which on a full battery is a twenty-minute loop and makes people want to skip it. An aggregating gate hands you the whole list once. It's a small change, and it decides whether the gate feels like a collaborator or an obstacle — and a gate people resent is a gate people find ways around.

The emergency bypass exists, and it is all-or-nothing on purpose: one environment variable skips the entire battery, never a convenient subset, and CI re-runs everything regardless. There is no partial-credit mode to get comfortable in.


What it costs

An honest accounting, since posts like this tend to skip it.

The rules need their own tests, and roughly as much test code as rule code. A rule that false-positives once destroys its own credibility; a rule that silently stops firing is worse than no rule. All in, enforcement runs to a low single-digit percentage of the source tree. It is not free and it is not glamorous.

Rules need maintenance. Every rule that names a specific file, attribute, or method is coupled to a structure that might legitimately change. Refactor the file and the rule has to move with it.

Some rules are ugly. Grepping a shell script as a string to confirm that no step ends in || true is not elegant. The elegant alternative did not exist.

Precision is expensive up front. Getting the "at entry" semantics right took real thought about the language's execution model. The cheap version would have shipped in an afternoon and been suppressed within a month.

What you get in return is an architecture document where every load-bearing sentence has a test. New engineers — and, increasingly, AI coding agents, which are extremely good at producing plausible code that violates invariants nobody told them about — get corrected by the toolchain in seconds, instead of by a reviewer in a day, or by production in a quarter.


If you want to try this

You don't need dozens of rules. Start with one, and pick it well.

  1. Take your last real incident and ask whether the mistake had a syntactic shape. Not all of them do. But the ones that do — a call in the wrong place, an import across a boundary, a field on the wrong model — are exactly the ones that recur, because nothing structural is stopping them.
  2. Write it as a tree walk, not a regex. The standard library's AST module is the whole dependency. Fifty lines gets you a working rule.
  3. Make the message teach. State what's wrong and what to do instead. A bare rule identifier trains people to suppress.
  4. Ship it blocking on day one, with the existing violations in a named, visible, shrinking list. Advisory rules stay advisory forever.
  5. Give it a reasoned escape hatch — and make the reason mandatory.
  6. Then write the meta-check, before you have five rules and no habit of protecting them. It is a hundred lines, and it is the difference between a gate that holds for years and one that quietly dissolves.

The underlying claim is small and, I think, hard to argue with: an architectural rule that isn't executable isn't a rule, it's a preference. Preferences lose to deadlines. And a rule that is executable but can be switched off in one line during an incident is a preference with extra steps — which is why the last rule you write should be the one that watches the others.