A coding model agrees to every rule you give it. Tell it to avoid duplicating code and it says yes — then writes the same function three times in three different files because it was more convenient at the time.
We keep a CLAUDE.md full of these rules: thin resources, no cross-module imports, files under 300
lines. The model reads them, agrees, and violates them three edits later when it’s concentrating on something else. Prose is advisory. Under pressure, advisory loses.
For rules that matter the most, we don’t use prose. They’re enforced by scripts that read every change the model makes and reject the ones that break a rule. The model doesn’t get a vote: a rejection comes back as a concrete, named error, and fixing concrete named errors is the one thing a model is reliably good at. There’s no instruction to forget and nothing to talk past.
Three places to stop a bad change
The checks fire at three points, because the cheapest moment to catch a mistake depends on the mistake.
Before the edit lands. Some files must never be edited by hand. Our database bindings are generated from the schema — you change the schema and regenerate, you never touch the output. 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 lands. Most checks read the file the model just wrote and test one rule against it. The architecture rules live here. Our resources stay thin — a web resource
routes a request and nothing else: no transactions, no caught exceptions, no reaching into the database. That’s enforced 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
A model will happily open a transaction inside a resource because it compiles and works. It’s still wrong: that logic can’t be reused or tested on its own, and transaction boundaries scattered through the web layer makes it hard to reason about the transaction model. The check rejects it, the failure becomes the next thing to fix, and the work moves into a service where it belongs.
When the model stops. I will detail this in another article — not only all tests must pass, but an adversarial agent will run a detailed code review following a detailed methodology, and report back any findings to the dev agent.
The gates, in one table
The after-edit hook enforces around a dozen rules. A selection of what each one fires on:
| Gate | Fires on | Why |
|---|---|---|
| Microservice boundary | one microservice importing another’s internals | microservices talk through APIs, not each other’s internals |
| Layer | @Transactional, catch, or a repository import in a rest/ file | resources stay thin and testable |
| File length | a file over 300 lines | length is an over-engineering smell |
| Duplicate file | a new class with a name that already exists | reuse over reinvention |
| Utility placement | a *Utils / *Helper dropped into a feature module | shared 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 |
| Flyway migration naming | a database migration off the V001__name convention | migrations have to apply in a known order |
Some are hard failures. Others are smells — a 320-line file isn’t a sin — and those still block but invite a reason: the model may proceed if it explains itself. Getting that split right mattered. Early versions blocked everything equally and the model would get stuck fighting a smell-level rule it couldn’t satisfy or override, burning a whole turn on a 300-line limit.
A check is a small script, wired so the model can’t skip it
The date-sentinel gate, in full, is representative — most are this small:
#!/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 1
fi
What makes it hold is the wiring. The checks are registered against the edit loop, so they run on their own — the model never decides whether to:
// runs automatically, every time, not on the model's say-so
"afterEdit": ["generated", "layer", "module-boundary", "file-length", "sentinel"],
"afterSession": ["test-touched-modules"]
Most of the scripts are trivial. The engineering is in the choices: which rules deserve a gate, how to write each so it never fires on a false positive, and where to wire it so the model can’t step around it.
The compile gap
Notice what the after-edit checks don’t do: compile. Compiling the project after every keystroke would make the model unbearable to work with. The trade is deliberate, and it has a cost — the model can write code that doesn’t compile and not find out right away.
So the heaviest check runs at the end. Every edit quietly records which module it touched, and when the model tries to finish, a final gate runs the full test suite for each touched module. Compiling is the first thing a test run does, so a broken build and a broken test surface in the same place — once, at the end, instead of after every line. The model cannot declare itself done on red: the turn doesn’t end until the modules it touched are green.
What a check can’t do
A check confirms the code is built correctly. It can’t tell you the code is right — whether a limit matches the regulation, whether a number means what the business thinks it means. A grep doesn’t read regulations. Judging that is the job of review — an adversarial pass that takes a run at business sense too, topped by a human who holds final authority over the answer. That’s where the next posts go. The gates exist so that scrutiny lands on whether the number is right, and never on catching a transaction in the wrong place.