You start a project with Claude. A few weeks in you have a file of rules, and every line got there because something went wrong once.
The agent pushed to main. Never commit to main; always work on a branch.
A test reached the live API and moved real data. Tests never call production.
A handler wrote to the database directly. All writes go through the repository.
That file is everything the project has learned, and it's the one part of it nobody ever checks.
Here's the catch: a dead rule and a live rule are the same sentence. A line that's been ignored for weeks looks exactly like the line that's holding.
The first one is easy. Git has branch protection, you flip it once and stop thinking. Now look at the other two, and at everything under them in your own file. Which of those has a switch?
One question sorts the file: if this rule is broken right now, does anything fail? A test, a linter, a hook, a CI gate. Any of them. Nothing else predicts it, not the wording and not what the incident cost. If the answer is "nothing fails, someone might notice in review," you wrote a wish, and wishes decay.
So I counted
I went through 57 of my own sessions and counted, for every rule that leaves a mechanical trace, how often I did the forbidden thing and how often I did the prescribed one. The file split in two, and the split had nothing to do with which rules mattered.
Rules with a machine check behind them: 0 violations in 3,131 opportunities. One near-miss, in a test fixture.
Rules held in memory only: 30–45%, drifting down. One ran at 60% across the first half of my sessions and 31.8% across the second, and the file gave me no hint of that.
Half of that zero is tautology, and I'd rather say so first. If the hook refuses the command, the violation never reaches the transcript I'm counting. That's the whole point of a guard, but it means the number describes my machinery, not my discipline.
"Add a guard" is not advice
It's the start of one. Guards come in rungs, and you're probably standing lower than you have to.
| # | Rung | What it does | Breaking it |
|---|---|---|---|
| 1 | Regeneration | the artifact is rebuilt; the edit is erased | impossible |
| 2 | Blocked at the action | a hook rejects the tool call before it runs | impossible |
| 3 | Will not compile | the wrong call has no valid shape | impossible |
| 4 | AST guard | a test parses structure, not text | caught in CI |
| 5 | Cross-artifact guard | two artifacts must agree with each other | caught in CI |
| 6 | Runtime detector | production logs an error you can see | caught after the fact |
| 7 | Written where it always loads | the only option for meaning-rules | visible if you look |
The top three don't detect a violation. They make it impossible or refuse it outright. Everything below reports afterwards.
1. Regeneration
Some files aren't written by hand at all: a routing table built from an API spec on every build, a client library generated from a schema.
When the rule is "do not edit this file by hand," nothing needs to enforce it. The agent edits the file, the next build regenerates it, the edit is gone. Every rung below detects a violation once it has happened. This one means it never persists, so the rule has no job left to do.
Before writing "never hand-edit X," ask whether X could be generated from something you already have. It costs a source of truth, a build step, and a header line in the generated file saying so. Skip the header and the agent watches its change vanish on the next build with no idea why.
2. Blocked at the action
A hook sees the proposed tool call before it runs and can refuse it. The agent decides to force-push, your script reads the command, matches it against a short list, returns a refusal. The push never happens.
The refusal should carry a reason. The agent reads it and takes another route; a bare "no" gets the same command retried a second later.
An interception doesn't have to end in refusal. It can substitute. A hook catching plain-text search can route the request to a code-navigation service instead, so the agent gets a real answer where it would have got a wall of matches. That hook makes the work better while it stands guard.
The failure mode is a pattern that's too broad. A hook matching push will eventually refuse something you wanted, and you'll find out mid-task with no way to say "yes, this one." Write the match narrow and read the refusal log for a week.
3. Will not compile
Sometimes a rule can be replaced by a shape the language won't accept.
Say the rule is "never build a file path by gluing strings together." Written down, that's a wish. Give the function a parameter type that only a properly constructed path satisfies, and a glued string stops being a mistake someone might catch in review. It stops being expressible.
# rung 7: a sentence in a file
def read_config(path: str) -> Config: ...
# rung 3: the wrong call has no shape
def read_config(path: SafePath) -> Config: ...
The compiler enforces it on every call and there's nothing left to maintain.
The second rule from the top of this article lives here too. Tests never call production stays a wish as long as the test can build any client it likes. Give the harness a client type with no production constructor and nobody has to remember the rule.
This rung is only as real as your type checking. If the checker doesn't run in CI, or your tests sit in a directory it skips, rung 3 does nothing.
4. AST guard
A test that parses the syntax tree and asserts a structural property: no handler calls the database driver directly. That's the third rule from the top of this article, moved off the page and into CI, and it took one test. The same shape catches a route carrying a secret in a path parameter, or a log field carrying a biometric vector.
Structural checks survive renaming and reformatting. Text search doesn't. A guard checking that a feature flag had been removed searched for the flag's name as a substring and missed its own deletion, because a longer variable still contained that name as a prefix.
Rungs 5 to 7: the ones that only report
A cross-artifact guard asserts that two different files agree: every environment flag the code reads is declared in the deployment file, every job kind in code is admitted by the database constraint. Both sides can be internally consistent and still disagree, and that bug survives review, survives the linter, survives the suite, then turns up in staging as a config key nobody set.
A runtime detector covers what depends on real data no fixture reproduces. Pick a condition that's impossible rather than unusual: a few stored references failing to resolve against a rebuilt document is normal, but if every reference fails, the thing they point into changed shape underneath them. Fire on "all of them failed," stay quiet on "some did."
Then the bottom rung, where most rules live today. "Do not treat a cache miss the same as a cache error" can't be checked by any parser, because both are a function returning without data and the difference is intent. Rules about meaning will always exist and no ladder reaches them. Text is the only mechanism left, which makes where you put that text a separate problem. In my audit, these are the ones that decayed.
What to do with this
Open your rules file. For each line, ask what fails today if it's broken, then how far up the ladder it could go. Most of mine sat two rungs below where they could have.
The two cheapest moves: anything protecting a file that could be generated belongs on rung 1, and anything phrased as "never run" or "never touch" belongs on rung 2, where the hook is a few lines of script.
Then check the file against what you actually did:
Read my session transcripts for this project.
Pick two or three rules from my instructions file that leave a
mechanical trace: a forbidden command, a banned import, a tool
I am supposed to use instead of another one.
For each rule, count two things:
how many times I did the thing the rule forbids
how many times I did the thing it prescribes
Report both raw numbers, never a percentage on its own.
Then split my sessions in half by date and report each half
separately.
The last instruction is the one that earns its keep. One figure tells you where you are; two halves tell you which way you're moving. A decaying rule looks healthy in the average.
Two rules in my file, written the same week by the same person, with the same conviction behind both. One held 3,131 times. The other lost half its compliance and I didn't notice. The only thing that differed was whether anything would have failed.
From an audit of 57 of my own agent sessions, 23,226 tool calls. Compliance was matched over command text, so read those counts as directional; the 0-of-3,131 and the 60%/31.8% split were checked case by case.
Top comments (2)
The 57-session count is the useful bit here. I keep seeing CLAUDE.md treated like a constitution, when it behaves more like a TODO file unless a hook or test can fail. My favorite low-friction pass is to add a column beside each rule for where it is enforced. If that column stays blank, the rule is only incident documentation with better typography.
The “what fails if this rule is broken?” test is a useful way to audit these files.
One caveat for security rules: a command-text hook can look like rung 2 while still behaving like rung 7 if the same capability is reachable through another path. Blocking
git pushdoes not help if the agent can invoke a wrapper, use a different Git client or call the hosting API directly.For destructive or privileged actions, I’d define the control around the capability rather than one command spelling: canonicalize the requested operation, check the resolved target, and enforce authorization at the tool or service boundary. Then test bypass variants as fixtures.
That also gives the rule a measurable contract: every path capable of performing operation X must pass through guard Y. Without that coverage check, a perfect refusal rate may only mean the agent stopped using the one syntax the hook recognizes.