Quietly Wrong

Rules a model can't argue with

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.

model edit before edit block forbidden files after edit test one rule at stop compile + run tests accepted pass fail — handed back as the next thing to fix

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 Stop hook 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.


Have a project in mind?

A short intro call is the fastest way to find out if we're a fit.

Get in touch