Chapter 7 of 7 · 8 min read
A rule that must never break belongs in a gate, not a body
On this page
The number on the table is 1.4 expected violations per quarter, and the consequence attached to it is an ACCESS EXCLUSIVE lock on a 210-million-row table during business hours. The team priced the last one: 41 minutes of blocked writes, a partial checkout outage, 90 minutes of incident time across four people, and a customer-facing status page entry. Against that, a check that reads migration files and refuses the bad shape is a day of work, and it does not have a compliance rate.
That is the whole move. The rule stops being a sentence the model reads and becomes a gate: a condition some other piece of software evaluates on the work as it passes, refusing to let it through when the condition fails. The thing evaluating it does not have a good day or a bad day.
Three places to put the check, and they are not interchangeable
Deterministic enforcement has three shapes and they differ in when the agent finds out, which turns out to matter more than how thorough they are.
| Tool-side validation | Check on the artefact | Unrepresentable state | |
|---|---|---|---|
| Where it runs | inside the tool the agent calls | pre-commit hook and CI | in the format itself |
| Agent learns | at the call, mid-turn | after writing the file | at the moment of writing |
| Catches | bad calls only | anything in the artefact | the shape, always |
| Misses | work that never calls the tool | plans that never become files | anything outside the format |
| Build cost | hours | ~1 day | weeks, plus a migration |
| Failure blast radius | blocks one call | blocks a commit | blocks a whole class of work |
| Bypassable | no | --no-verify, CI override | no |
The migration rule takes the middle column, because the artefact exists — a .sql file in migrations/ — and the check is a parse away.
# tools/schema_check.py — fails when a type change and a backfill share a file
import re, sys, pathlib
ALTER_TYPE = re.compile(r"ALTER\s+COLUMN\s+\w+\s+(SET\s+DATA\s+)?TYPE", re.I)
BACKFILL = re.compile(r"^\s*(UPDATE|INSERT\s+INTO\s+SELECT)\b", re.I | re.M)
def check(path: pathlib.Path) -> list[str]:
sql = re.sub(r"--.*?$", "", path.read_text(), flags=re.M)
a, b = ALTER_TYPE.search(sql), BACKFILL.search(sql)
if not (a and b):
return []
line = lambda m: sql[: m.start()].count("\n") + 1
return [
f"{path}",
f" SPLIT_REQUIRED: type change (line {line(a)}) and backfill "
f"(line {line(b)}) in the same migration",
" Fix: keep the ALTER here; move the UPDATE into a new migration "
"that runs after it.",
" Procedure: skills/db-migrations/references/split.md",
]
failures = [msg for p in pathlib.Path("migrations").glob("*.sql") for msg in check(p)]
print("\n".join(failures) or "schema-check: ok")
sys.exit(1 if failures else 0)The rejection message is engineered, not incidental. It names a machine-readable token (SPLIT_REQUIRED), gives the two line numbers, states the repair as an action, and points at the file containing the procedure. That shape is what lets the agent fix the problem in the same turn rather than reformulating from scratch, and the difference is measurable: an earlier version that printed schema-check failed took an average of 2.6 rounds to reach a passing file, and this one takes 1.1.
The same check runs in two places on purpose. Pre-commit, a hook git runs on the author's own machine before it records a commit, is where the check is cheap, catching the problem while the agent still has the context to repair it; CI, the pipeline that runs on the server after a push, is where it is authoritative, because pre-commit hooks can be skipped and a check that only runs locally is a convention rather than a gate.
The first column is worth building when the dangerous thing is a call rather than a file. A migration runner exposed as a tool can reject its own input before anything executes:
// The tool refuses the call; no instruction is involved.
const RunMigration = {
name: "run_migration",
input: z.object({ sql: z.string(), env: z.enum(["dev", "staging", "prod"]) }),
async handler({ sql, env }) {
if (ALTER_TYPE.test(sql) && BACKFILL.test(sql)) {
return {
isError: true,
content: [{ type: "text", text:
"SPLIT_REQUIRED: run the ALTER alone, then the UPDATE as a " +
"separate migration. Nothing was executed." }],
};
}
return exec(sql, env);
},
};The third column is the strongest and the one teams reach for last, because it requires owning the format. If migrations are authored as a small typed DSL — a domain-specific language, here a narrow purpose-built format for expressing schema changes — rather than raw SQL, a discriminated union with an alter variant and a backfill variant and no way to express both makes the violation unwriteable. The cost is that every existing migration has to move to the new format and every author has to learn it, which is why this is a decision about a codebase rather than about a skill.
The body gets shorter and says something different
Once the gate exists, the skill stops carrying the rule and starts carrying the interface to it.
1. Run `make schema-check` before proposing any migration.
2. If it reports SPLIT_REQUIRED, split the file as described in the output
and re-run until it passes.Those two lines pass the deletion test from the fifth chapter easily, because without them the agent writes the file and stops, and the gate fires in CI twenty minutes later against a human instead of against the agent. They also replace three items from the nine-item list — the rule itself, an item about testing migrations against a copy of production, and an item reminding the author to check lock behaviour — since all three are now conditions the check evaluates.
Over the following quarter, 23 migrations produced zero shipped violations. Three of the 23 failed the gate on the first attempt and all three passed on the second, with a median of 14 seconds between the failure and the corrected file. That last number is the incidental benefit and it is worth taking seriously: the gate is an instrument as well as a barrier. Before it existed, the team's estimate of the model's violation rate came from whatever reviewers happened to catch. Now it is 3 of 23, measured, and if a model upgrade moves it to 8 of 23 the graph shows it that week.
Would you build a gate for it
The test cuts both ways, which is what makes it useful on a list that has grown to twelve items. For each rule, ask whether you would spend a day building a deterministic check for it. A yes means the rule leaves the body and the body gains a line about the gate. A no means you have accepted that it will sometimes be violated, and the rule then has to justify its place as a step against the compliance cost it imposes on its neighbours.
| Candidate rule | Gate? | Outcome |
|---|---|---|
| No type change plus backfill in one migration | yes | make schema-check; body keeps 2 lines |
Constraints added NOT VALID, validated separately | yes | same check, second condition |
SET statement_timeout on every migration session | yes | the runner sets it; the agent cannot omit it |
Index names follow ix_<table>_<cols> | already gated | linter catches it; cut from the body |
| Prefer the simplest migration that works | no | judgement; cut, it fails the deletion test |
| Write a rollback note in the PR description | no | violation costs a review comment; keep as a step |
Twelve items came out as two gates, one existing linter, three steps and six deletions. The interesting rows are the last two. "Prefer the simplest migration that works" is a rule nobody would build a check for and nobody wants to delete, and that combination is the signature of an aspiration rather than a rule — it has no violation condition, so it cannot be gated, cannot be reviewed, and cannot be complied with in any way you could distinguish from ignoring it. The rollback note is the honest opposite: no gate, a real violation condition, and a consequence small enough that a 90% rate is fine.
What gates do not cover
A check on an artefact sees only what reaches the artefact. Whether the agent asked a clarifying question before choosing an approach, whether it considered an online schema change tool, whether its explanation to the reviewer was accurate — none of that is in the .sql file, and no amount of parsing recovers it. Rules about reasoning and communication stay in the body with a residual, and pretending otherwise produces gates that check for the presence of a comment rather than for the property the comment was supposed to indicate.
False positives are the failure mode that destroys a gate quietly. The NOT VALID check shipped with a pattern that also matched constraint definitions inside CREATE TABLE, giving a 12% false-positive rate in its first week; it was bypassed four times with --no-verify, the git flag that commits without running the hooks at all, twice by the agent and twice by a person, and after the second bypass the team briefly discussed removing it. Narrowing the pattern to files under migrations/ and excluding CREATE TABLE bodies brought the rate to about 1%. The health metric for a gate is its bypass count rather than its catch count, and a gate whose bypass is easy and whose false positives are common trains everyone, human and agent, to route around it. Making bypass require a named human and recording it in the commit is what keeps that visible.
Gates also decay. A check written against the migration conventions of 2025 rejects a valid pattern introduced in 2026, and the person who hits it is under deadline pressure, which is how --no-verify becomes normal. Someone has to own the check the way they own the skill.
The most important limit is that a gate does not do the skill's job. The team measured this directly by running the fixture with the gate installed and the skill removed: 11 of 23 first attempts failed, and the average number of rounds to reach a passing file rose to 2.4, because an agent that has never encountered two-phase migrations discovers the constraint by being rejected and then has to invent the split from a one-line error message. With both, 3 of 23 failed at 1.1 rounds. The skill is what makes the first attempt right and the gate is what makes the last attempt correct, and dropping either one costs turns, tokens and — when the gate is the one dropped — occasional production locks.
What you now have, and what it is for
The skill that has come out of these seven chapters is a specific object: a description written from a request log in the vocabulary of the person with the problem, with the neighbouring tasks named as exclusions; one boundary that a fixture can measure; a body of imperative steps that each survive a deletion test, with the one example that pins a format and the one exception that would otherwise be silently wrong; the bulk of the material in references/ and bin/, fetched when a task needs it; and the two or three rules with no acceptable violation rate moved out into checks that do not depend on the model reading them.
What it has not had is contact with a real library and real traffic. Every number in these chapters came from a fixture, and a fixture is a sample of requests somebody chose. The skill now goes into a directory alongside a dozen others that compete for the same requests, in front of people who will phrase things nobody predicted, and the artefacts left behind — the labelled request fixture, the deletion-test tasks, the compliance replay, the gate's failure count — are the instruments for watching what happens next.
Writing a skill — read the current version at https://daviddominguez.dev/learn/en/writing-a-skill/a-rule-that-must-never-break-belongs-in-a-gate-not-a-body