references/enforcement.md
Enforcement: making rules mechanical
A rule is only as real as the mechanism that holds it. This file is about moving rules down the ladder.
| Level | Mechanism | Holds because | Decays? |
|---|---|---|---|
| 1 | Tribal knowledge | someone remembers | immediately |
| 2 | Written in a doc | someone reads it | quickly |
| 3 | Caught in code review | someone notices | under deadline |
| 4 | Lint rule, CI check | the machine rejects it | no |
| 5 | Type system | it does not compile | no |
| 6 | Impossible by construction | it cannot be expressed | no |
Why levels 1 to 3 fail
Not because people are careless. Because they depend on attention, and attention is the resource that vanishes exactly when it is most needed: the Friday release, the incident, the week the team is short.
Worse, level 3 fails asymmetrically. A reviewer who is tired catches the obvious violations and misses the subtle ones, so the rule is enforced against newcomers writing plainly and not against experts writing densely. That is the opposite of what you want.
There is also a new reason, which is that a substantial share of code is now written by something that never read your document. A model infers your rules from the code it can see and from your configuration files. It does not infer them from a wiki. A rule at level 2 is invisible to the thing writing much of your code; a rule at level 4 or 5 is fed back to it on every run.
Level 4: lint and CI
The workhorse. Anything expressible as a pattern over source belongs here.
Structural boundaries. The highest-value rules in any layered codebase.
// eslint.config.js - the design system may not learn the domain
{
files: ['src/ui/**', 'src/lib/**'],
rules: {
'no-restricted-imports': ['error', {
patterns: [{ group: ['@/features/*'], message: 'ui/ must not depend on features.' }],
}],
},
}
For real dependency-direction enforcement use eslint-plugin-boundaries or eslint-plugin-import-x zones, which understand layers rather than paths. Hand-written path patterns get stale as directories move.
Python has the same thing in import-linter, declared in setup.cfg or pyproject.toml:
[importlinter:contract:layers]
name = Layered architecture
type = layers
layers =
app.routers
app.services
app.repositories
That contract is fastapi-backend's entire layering section, made mechanical. It is roughly ten lines and it never gets tired.
Cycles. import/no-cycle in ESLint, import-linter forbidden contracts in Python. Turn it on early. Turning it on late means paying off the existing cycles first, which is why most codebases never do.
Naming. Ruff's pep8-naming (N) rules, ESLint's @typescript-eslint/naming-convention. This is how naming-conventions stops being a document.
Formatting. Not a maintainability rule in itself, but it eliminates a whole class of review noise, which protects the attention budget for level 3. Never discuss formatting. Run Prettier or Ruff format on commit and forget it.
The blanket rules worth the argument:
no-floating-promisesandrequire-awaitin TypeScriptexhaustive-depsas an error, not a warning. An exhaustive-deps warning is a
bug you have not hit yet.
no-restricted-syntaxfor whatever your team keeps doing wrong. It is an
escape hatch for rules nobody has written a plugin for.
CI checks beyond lint:
- A migration-safety check, so a destructive migration cannot land unnoticed
- Dependency audit on a schedule, not on every push, so it does not become noise
people click past
- A check that fails the build if a new file lands outside the known directories
That last one is unusual and disproportionately effective in a repo with a prescribed layout. It converts "where does a new file go" from a question into an error message.
Level 5: the type system
Types are the cheapest enforcement that exists, because the feedback arrives while you are typing rather than in CI.
Turn on the strict settings. strict: true and noUncheckedIndexedAccess in TypeScript, mypy --strict or pyright in Python. Retrofitting strictness is real work; starting with it is free.
Make illegal states unrepresentable. The single highest-leverage typing idea, and it is a design technique, not a typing trick.
// three booleans, eight states, five of them nonsense
type State = { isLoading: boolean; isError: boolean; data?: Data };
// one union, three states, all of them real
type State =
| { status: 'loading' }
| { status: 'error'; error: Error }
| { status: 'ready'; data: Data };
The second version deletes an entire category of bug rather than defending against it, and it makes the exhaustiveness of a switch a compile error rather than a review comment.
Distinguish values that must not be swapped. A UserId and an OrderId are both strings, and passing one where the other belongs is a bug the compiler will happily allow. Branded types in TypeScript, NewType in Python:
type UserId = string & { readonly __brand: 'UserId' };
Use this where the mix-up is plausible and expensive: identifiers, currency minor units, durations. This is naming-conventions' units rule at level 5 rather than level 2.
Parse, do not validate. Convert untrusted input into a typed value once, at the boundary, and let the type carry the guarantee inward. Pydantic in FastAPI, zod in TypeScript. The alternative is defensive checks scattered through the call stack, each of which is a place someone can forget.
any and # type: ignore are debts. Require a comment naming why. A bare suppression is a rule turned off with no record of what it was protecting.
Level 6: impossible by construction
The best rules are the ones nobody can express a violation of. Usually a design change, not a tool.
- A query path that cannot run without a tenant filter.
mongodb-production
makes exactly this point: the model is not what protects tenants, the enforcement is. If the only way to reach the database takes a tenant as a required argument, the leak cannot be written.
- A
yielddependency instead oftry/finallyin each handler. Cleanup that
cannot be forgotten because it is not at the call site.
response_modelon every route. A field added to the persistence model
cannot leak, because the serializer does not know about it.
- Typed routes instead of a
ROUTESconstant. A moved route becomes a
compile error, and there is no second source of truth to drift.
- Secrets that are not in the build.
react-native-exposays any
EXPO_PUBLIC_ value is readable by anyone with the app, so the enforcement is not a rule about care, it is that the secret lives on the backend and is never in the artifact at all.
The pattern is always the same: remove the opportunity rather than police it.
Ownership and drift
Two mechanisms that are not about code:
CODEOWNERS. Cheap and effective. It routes review to whoever holds the context, which is the only way level 3 works at all in a repo bigger than a team.
Scheduled dependency updates. Renovate or Dependabot, batched, on a schedule. This is enforcement of a rule nobody thinks of as a rule: stay close to upstream. The cost of upgrading is roughly linear in how far behind you are until it becomes a rewrite. Small, frequent, boring updates are strictly cheaper than an annual reckoning, and this is the single most common way a working codebase becomes a legacy one without anyone deciding.
Choosing a level
Do not put everything at level 6. The ladder has a cost gradient too, and over-enforcement produces a codebase where doing anything ordinary requires defeating a mechanism.
The test: what does a violation cost, and how often will it happen?
| Cost of violation | Frequency | Put it at |
|---|---|---|
| High and silent | Any | 5 or 6 |
| High and loud | Rare | 4 |
| Moderate | Common | 4 |
| Low | Common | 4, if trivial to add |
| Low | Rare | 2, or drop the rule |
"High and silent" is the row that matters: tenant leaks, secrets in bundles, money as floats, missing authorization. These fail without an error message, so no amount of review reliably catches them, and they must be structural.
And be honest about the last row. A rule nobody will enforce and nobody will miss should be deleted from the document, not left there to make the document untrustworthy. A style guide with unenforced rules teaches people to ignore style guides.