The poetic licence of LLMs
Ask an LLM to write code under a set of rules and it assures you that, of course, every rule will be respected. No duplicated code, no long files, no over-engineering. Then it writes the same function three times, or a 500-line file that handles impossible scenarios and shrinks to 20 lines once corrected.
We tried writing these rules in a CLAUDE.md (or AGENTS.md): the model reads them, agrees with them, and breaks them
regularly.
Rules written in instruction files are advisory, and often ignored.
So the rules that matter most are defined as scripts that check every change the model produces and reject the ones that break a rule. The model has no say, and cannot move on until the reported error is fixed.
These rules work because there is no instruction to forget and nothing to negotiate.
Three places to stop a bad change
The checks run at three points, because the cheapest moment to catch a mistake depends on the mistake.
Before the edit
Some files must never be edited by hand: the objects jOOQ generates from the database schema, for instance. If the agent tries to edit one of them, a check runs before the write and refuses it outright; the file is never even opened.
# runs before the edit — a non-zero exit means the write never happens
if [[ "$file" == */generated/* ]]; then
echo "✗ generated code — change the schema and regenerate, don't edit this" >&2
exit 2
fi
After the edit
Most checks read the file the model just wrote and test one rule against it. This is where the architecture rules live.
For example, some files exist only to expose and document a REST API, then pass the request on to a business service. Transactions scattered across the web layer make the transaction model hard to follow.
So we have rules that forbid these objects from opening database transactions. The rule is checked on every edit to a
file in a rest/ package:
# post-edit-gate.sh (Hook: PostToolUse) - runs after an edit to a file in the rest folder
if echo "$RELATIVE" | grep -qE '/rest/.*\.kt$'; then
if grep -qE '@Transactional' "$FILE_PATH"; then
echo "GATE 6 FAILED: REST resources must not use @Transactional." >&2
echo "Move transaction logic to the service layer." >&2
exit 2
fi
fi
If the agent puts a transaction in a file that exposes our REST API, the check rejects the change, the failure becomes the next thing to fix, and the agent moves the transaction to the service layer.
When the model is done
Not only must all tests pass, an agent also runs an adversarial code review following a detailed method, and reports its findings to the development agent.
A few example rules
The post-edit hook enforces many rules. A sample:
| Rule | Fires on | Why |
|---|---|---|
| Microservice boundary | one microservice importing another’s implementation | microservices talk through their APIs, not their internals |
| Layer | @Transactional, catch, or a repository import in a rest/ file |
REST resources stay thin and testable |
| File length | a file over 300 lines | length is a symptom — the code may be doing too much |
| Duplicate code | a new class or function whose name already exists | reuse over reinvention |
| Utility placement | a *Utils / *Helper dropped into a feature module |
reusable utilities live in one shared module |
| Constants sentinel | a literal such as 9999-12-31 instead of a named constant |
a constant carries the intent the literal hides |
| Migration naming | a Flyway migration off the V001__name convention |
migrations have to apply in a known order |
Some block outright: the change is rejected. Others flag a symptom — a 320-line file is not necessarily a fault — and only warn: the change goes through, the message is shown.
Getting that split right matters: early versions blocked everything at the same level, and the model would get stuck against a hygiene rule it could not satisfy, losing a whole turn on a 300-line limit.
A check is a small script the model cannot skip
The date-sentinel check, in full, is representative — most rules fit in as few lines:
#!/usr/bin/env bash
file="$1"
[[ "$file" == *DateConstants.kt ]] && exit 0 # the one place the date is allowed
if grep -nE '9999[-, ]+12[-, ]+31' "$file"; then
echo "✗ sentinel ($file): use DateConstants.OPEN_ENDED, not a literal date" >&2
exit 2
fi
Most of these scripts are simple. The engineering is in the choices: which rules to implement, how to write them to avoid false positives, and how to wire them so the model cannot avoid them.
The set is not fixed either. A rule that has not fired in months is removed — most often after a change of model.
Blind spots
No post-edit script compiles anything: compiling the project on every change would make the work too slow. We ask the model, in prose, to compile regularly and run the tests its change seems to affect. Recent models largely do this on their own, and choosing the relevant tests is left to the model’s judgment. But this whole post says that prose gets forgotten, so it needs one more guardrail.
Every edit records the module it touches, and when the model tries to finish, a final check runs the full test suite of every module concerned, which catches regressions. The model is not allowed to finish until all tests are green.
Wiring into Claude Code
What makes these rules robust is that they are not under the agent’s control.
Claude Code, for example, exposes hooks: commands declared in .claude/settings.json and run on each event of the
agent — before a tool, after it, at stop. The model never
decides whether they run.
"hooks": {
"PreToolUse": [{"matcher": "Edit|Write", "hooks": [{"type": "command", "command": "hooks/pre-edit.sh"}]}],
"PostToolUse": [{"matcher": "Edit|Write", "hooks": [{"type": "command", "command": "hooks/post-edit.sh"}]}],
"Stop": [{"hooks": [{"type": "command", "command": "hooks/test-touched-modules.sh"}]}]
}
Each script receives a JSON describing the current call, including tool_input.file_path, the file concerned, and
answers with an exit code: 0, the agent carries on; 2, the
action is blocked and the error the script found is sent back to the model:
- Before the edit, the block prevents the write.
- After the edit, the file is already written and the message becomes the next thing to fix.
- At stop, the
Stophook sends any errors back to the model, which has to fix them before it can finish.
What a check cannot judge
These checks verify that the code follows basic rules; they do not say the code is correct. That falls to the adversarial review, then to our final sign-off. It will be the subject of a later post. The guardrails exist so that this review can focus on whether the logic is right, not on transactions lost in the wrong layer of the application.