DEV Community

RileyCraig14
RileyCraig14

Posted on

Hard rules vs soft rules: stop coding agents from handwriting migrations

A markdown file is a request. A hook that exits 1 is a rule.

This week someone asked how to stop coding agents from acting out of line. The easy case is "don't write the README." The hard case is "database migrations must be autogenerated, never handwritten." A sentence in AGENTS.md will not hold that line. A pre-commit hook will.

I shipped the pack as a same-day zip. The useful part is below. The full hook + CI + Cursor rule is the paid file.

Buy the pack: https://buy.stripe.com/7sYaEXcmI0obaWV4Q77ss0m ($24, Stripe Payment Link, no account needed to pay)

Soft vs hard

Intent Why markdown fails Hard check
Do not write README / CHANGELOG Models "helpfully" update docs Deny-list path match
Do not touch .env or lockfiles Models "finish the setup" Deny-list path match
Migrations must be autogenerated Models write SQL that drifts from the ORM Require a generator header or fail
Do not add dependencies Models grab a package to look busy Fail if the manifest changed

If you cannot write a check that exits 1, it is not a protocol. It is a vibe.

Drop-in AGENTS.md (free, use today)

# Agent protocol

You may edit files listed in the plan. You may add a test that proves the plan.
You may autogenerate a migration with the project generator, then stop.

You may not write README.md, CHANGELOG, docs/, or .env.
You may not handwrite a SQL or ORM migration.
You may not add a dependency.
You may not pass --no-verify.

If the plan needs a schema change and you cannot run the generator, stop. Do not write the SQL.
Enter fullscreen mode Exit fullscreen mode

Copy that to AGENTS.md and CLAUDE.md. Soft. Necessary. Not enough.

The check that makes it hard

Put this at tools/check-protocol.py. The hook and the GitHub Action both call it.

import pathlib, re, sys

DENY = {"README.md", "CHANGELOG.md", ".env"}
MIG_RE = re.compile(r"(^|/)(alembic/versions|migrations|prisma/migrations|drizzle|supabase/migrations|db/migrate)/")
GEN = re.compile(r"(GENERATED-BY:|Auto-generated by|Generated by (Alembic|Prisma|Rails|Django|drizzle-kit))", re.I)
SQL = re.compile(r"\b(CREATE|ALTER|DROP)\s+(TABLE|INDEX|TYPE)\b", re.I)

def denied(p):
    return pathlib.Path(p).name in DENY or p.startswith("docs/") or p.startswith("secrets/")

failed = []
for path in [ln.strip() for ln in sys.stdin if ln.strip()]:
    if denied(path):
        failed.append("DENY " + path)
        continue
    if MIG_RE.search(path):
        body = pathlib.Path(path).read_text() if pathlib.Path(path).exists() else ""
        if body and not GEN.search(body) and SQL.search(body):
            failed.append("HANDWRITTEN MIGRATION " + path)
if failed:
    sys.stderr.write("\n".join(failed) + "\n")
    sys.exit(1)
Enter fullscreen mode Exit fullscreen mode

Pre-commit (.git/hooks/pre-commit):

#!/bin/sh
set -e
git diff --cached --name-only --diff-filter=ACMR | python3 tools/check-protocol.py
Enter fullscreen mode Exit fullscreen mode

That is the handwritten-migration case: the agent can still want to write ALTER TABLE. The commit dies anyway.

What is in the $24 zip

  • 01-hard-vs-soft.md — the matrix, with the dependency and schema rows
  • 02-AGENTS.md — the drop-in protocol
  • 03-no-handwritten-migrations.mdc — Cursor always-on rule
  • 04-pre-commit-protocol.sh — installable hook
  • 05-protocol-check.yml — GitHub Action on PRs
  • 06-check-protocol.py — the checker with editable deny-list and migration globs

Pay here: https://buy.stripe.com/7sYaEXcmI0obaWV4Q77ss0m

After payment the Stripe receipt is the ticket. Email that receipt to rileycraig14@gmail.com and the zip goes out.

This is a file, not a comment, not a retainer, not GEO.

Top comments (0)