Back to blogAgent-written database migrations

The review comment on the agent's PR was one word: "tidy." Fair enough. A single clean migration renamed `users.fullname` to `display_name`, the same diff updated all fourteen call sites, and CI was green. We merged it and the deploy started. For the next eleven minutes the old pods threw `column "fullname" does not exist` while Kubernetes rolled out one replica at a time.

The agent wasn't careless. Every check it ran showed it a world where only one version of the code exists. Production never looks like that.

The eleven-minute outage

Here's the entire migration:

sql
-- migrations/20260814093000_rename_users_fullname.up.sql
ALTER TABLE users RENAME COLUMN fullname TO display_name;

In Postgres a rename only changes the catalog. It takes milliseconds, and that speed is half of why it looks safe. Our migrations run as a pre-upgrade job before the Deployment rolls. So the schema changed first, and only then did six API replicas start a rolling update with `maxUnavailable: 0`. Every pod still on v41 kept sending the queries it had always sent:

text
ERROR:  column "fullname" does not exist at character 19
STATEMENT:  SELECT id, email, fullname, avatar_url FROM users WHERE id = $1

Profile reads, the session refresh path and an admin search all returned 500s until the last old pod was gone. Readiness probes, `maxSurge: 1` and a slow JVM warm-up stretched that to eleven minutes. Nobody hit rollback, and honestly that was luck, not judgment. Rolling back the image would have put every pod on code that reads `fullname`, against a schema where `fullname` no longer existed. Rolling back code doesn't roll back schema.

Why the agent did it

Walk through the loop the agent actually ran:

  • ▹Generate the migration and update the call sites.
  • ▹Spin up a fresh local Postgres (testcontainers, for us) and apply every migration from zero.
  • ▹Run the test suite, from the same checkout, against that database.
  • ▹Green. Open the PR.
  • ▹A reviewer reads one diff where schema and code change together. Nothing looks wrong.

At every step, code and schema are at the same version. The two-version window, where v41 code runs against the v42 schema, doesn't exist anywhere in the agent's context. No test fails. No log line shows up. No reviewer points at it. The agent can't learn from a failure it has never seen, and adding "be careful with renames" to the prompt only asks it to imagine one. Sometimes it does. When it doesn't, the next prompt tweak won't help either.

This is the gap. The left column is what the agent tests and the right column is what production runs. Keep it in view for the rest of the post, because every guardrail below exists to move a cell from the right column into the left one.

text
What the agent's loop tests         What production actually runs
---------------------------------   ------------------------------------------
code@N    + schema@N                code@N-1 + schema@N   (during every rollout)
                                    code@N   + schema@N   (after rollout)
                                    code@N-1 + schema@N   (after an image rollback)
                                    cron/ETL/other svc + schema@N  (always)

Guardrail 1: the N-1 compatibility job

If you only merge one thing, merge this. It made the biggest difference for us. CI checks out the release currently running in production, applies the PR's migrations, and runs the old integration tests against the result. "Old code plus new schema" stops being a hypothetical and turns into a red check the agent can read, reproduce and fix.

yaml
# .github/workflows/n-minus-1.yml
name: n-minus-1-schema-compat
on:
  pull_request:
    paths: ["migrations/**"]

jobs:
  n-minus-1:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: app_test
        ports: ["5432:5432"]
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s --health-timeout 5s --health-retries 10
    env:
      DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_test?sslmode=disable
      SKIP_TEST_MIGRATIONS: "1"   # old test harness must NOT rebuild its own schema
    steps:
      - name: Check out PR (schema N)
        uses: actions/checkout@v4
        with:
          path: new
          fetch-depth: 0

      - name: Find the release running in production
        id: prev
        working-directory: new
        run: |
          # Swap for your deploy system's source of truth if you don't tag releases
          TAG=$(git tag --list 'v*' --sort=-v:refname | head -n1)
          echo "tag=$TAG" >> "$GITHUB_OUTPUT"

      - name: Check out previous release (code N-1)
        uses: actions/checkout@v4
        with:
          ref: ${{ steps.prev.outputs.tag }}
          path: old

      - name: Install migrate CLI
        run: |
          curl -sSL https://github.com/golang-migrate/migrate/releases/download/v4.18.1/migrate.linux-amd64.tar.gz | tar xz
          sudo mv migrate /usr/local/bin/

      - name: Apply schema N (every migration in this PR)
        run: migrate -path new/migrations -database "$DATABASE_URL" up

      - name: Run N-1 integration tests against schema N
        working-directory: old
        run: make test-integration

      - name: Explain the failure
        if: failure()
        run: |
          # tee: write to the step log (what `gh run view --log-failed` shows) AND the run summary
          tee -a "$GITHUB_STEP_SUMMARY" <<EOF
          ## N-1 compatibility failed
          Code from ${{ steps.prev.outputs.tag }} failed against the schema this PR produces.
          During a rolling deploy both versions serve traffic at once, so this failure WILL happen in production.
          Fix the migration, not the old tests: make the schema change additive (expand) and
          leave the old column readable and writable until a later contract PR.
          EOF
          exit 1   # fail this step too, so its log is included in --log-failed

Two details decide whether this job tells the truth. First, lots of test harnesses run migrations during setup. Rails' schema load does, Django's test DB creation does, and so do most testcontainers fixtures. If yours does, the old checkout quietly rebuilds the old schema and the job passes for the wrong reason. Put the flag in place before you trust a single green run. Second, the explanation text matters more than you'd think. When Claude Code chases a red check, it usually pulls the log with `gh run view --log-failed`, which only shows output from failed steps. The run summary page isn't included. So the explain step tees its text into the log as well as the summary, and it fails on purpose. That lands the explanation in the agent's next prompt. Say what's wrong and which way the fix should go.

Guardrail 2: lint output written for the agent

We already ran Squawk. On the original PR it printed roughly this:

text
migrations/20260814093000_rename_users_fullname.up.sql:1:1: warning: renaming-column Renaming a column may break existing clients.

A human reads that and remembers the outage from two years ago. The agent saw a lint failure blocking a green build and did the cheapest thing that makes lint pass: it added an ignore comment and pushed. From where it sat, that was the right call. The message named a problem and offered only one action, which was silencing the rule.

So we wrap the linter. Squawk still finds the violations. A thin script turns each rule into instructions: the exact expand-step SQL, the state-file entry, and what not to touch in this PR. We excluded `ban-drop-column` from Squawk because the state-file check in Guardrail 3 owns drops.

python
#!/usr/bin/env python3
# ci/migration_lint.py <files...>
# Runs Squawk, then rewrites each violation as instructions the agent can act on.
import json, re, subprocess, sys

RENAME = re.compile(r"ALTER\s+TABLE\s+(\w+)\s+RENAME\s+COLUMN\s+(\w+)\s+TO\s+(\w+)", re.I)

TEMPLATES = {
    "renaming-column": """BLOCKED {file}:{line} rule=renaming-column
Pods running the previous release still query {table}.{old} during a rolling deploy.
Renaming it breaks them the moment this migration runs.

Replace this migration with phase 1 (expand) only:

  SET lock_timeout = '3s';
  ALTER TABLE {table} ADD COLUMN {new} <same type as {old}>;
  -- then copy lint/sql/sync_trigger.sql, substituting {table}, {old}, {new}

Then add this entry to migrations/inflight.yaml:

  - id: {table}-{old}-to-{new}
    table: {table}
    from: {old}
    to: {new}
    phase: expanded

Do not change application reads or writes in this PR. Backfill, switch and
contract are three later PRs, one phase each. Do not add ignore comments:
CI rejects them unless a human applies the migration-override label.""",

    "adding-not-nullable-field": """BLOCKED {file}:{line} rule=adding-not-nullable-field
Old pods INSERT without this column and will fail. Add it as NULLable in this PR,
backfill in the next PR, and add a NOT VALID check constraint + VALIDATE in a third.""",
}
FALLBACK = "BLOCKED {file}:{line} rule={rule}\n{msg}\nSee docs/migrations.md for the safe sequence."

def main(files):
    proc = subprocess.run(
        ["squawk", "--reporter", "json", "--exclude", "ban-drop-column", *files],
        capture_output=True, text=True,
    )
    violations = json.loads(proc.stdout or "[]")  # field names vary by Squawk version; check yours
    for v in violations:
        path, rule = v.get("file", ""), v.get("rule_name", "")
        m = RENAME.search(open(path).read()) if path else None
        table, old, new = m.groups() if m else ("<table>", "<old>", "<new>")
        print(TEMPLATES.get(rule, FALLBACK).format(
            file=path, line=v.get("line", "?"), rule=rule,
            msg=v.get("message") or v.get("messages"), table=table, old=old, new=new))
        print()
    sys.exit(1 if violations else 0)

if __name__ == "__main__":
    main(sys.argv[1:])

The sync trigger is the piece the agent can't reliably invent, so it lives in the repo where the lint message can point to it. It keeps both columns consistent no matter which code version does the write:

sql
-- lint/sql/sync_trigger.sql  (substitute users / fullname / display_name)
CREATE OR REPLACE FUNCTION users_sync_display_name() RETURNS trigger AS $$
BEGIN
  IF TG_OP = 'INSERT' THEN
    NEW.display_name := COALESCE(NEW.display_name, NEW.fullname);
    NEW.fullname     := COALESCE(NEW.fullname, NEW.display_name);
  ELSIF NEW.fullname IS DISTINCT FROM OLD.fullname THEN
    NEW.display_name := NEW.fullname;          -- old code wrote
  ELSIF NEW.display_name IS DISTINCT FROM OLD.display_name THEN
    NEW.fullname := NEW.display_name;          -- new code wrote
  END IF;
  RETURN NEW;
END $$ LANGUAGE plpgsql;

CREATE TRIGGER users_sync_display_name
  BEFORE INSERT OR UPDATE ON users
  FOR EACH ROW EXECUTE FUNCTION users_sync_display_name();

On Rails, strong_migrations lets you replace its messages directly, so do that. The defaults are good advice for humans and too long for agents.

ruby
# config/initializers/strong_migrations.rb
StrongMigrations.error_messages[:rename_column] = <<~MSG
  Old pods still read this column during a rolling deploy. Do not rename.
  This PR: add the new column (nullable) + the sync trigger from lint/sql/sync_trigger.sql,
  and add an inflight.yaml entry with phase: expanded. Nothing else.
  Do not use safety_assured; CI rejects it without the migration-override label.
MSG

Then close the escape hatch. Ignore comments pass only if someone other than the agent applied the override label. The check reads the GitHub event log, so the agent can't approve itself by applying the label with its own token, as long as it runs under its own bot identity. If your agent pushes with a human's token, fix that first.

bash
#!/usr/bin/env bash
# ci/no_unapproved_ignores.sh  (env: PR_NUMBER, AGENT_LOGIN, GH_TOKEN)
set -euo pipefail

if ! git diff origin/main...HEAD -- migrations/ db/migrate/ | grep -E '^\+.*(squawk-ignore|safety_assured)'; then
  exit 0
fi

ACTOR=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/events" \
  --jq '.[] | select(.event == "labeled" and .label.name == "migration-override") | .actor.login' | tail -n1)

case "$ACTOR" in
  "" | *"[bot]" | "$AGENT_LOGIN")
    echo "Lint-ignore comments found. Remove them and fix the migration as the lint output describes."
    echo "Only a human can approve an override, by applying the migration-override label."
    exit 1 ;;
esac
echo "Override approved by @$ACTOR."

Guardrail 3: one phase per PR, enforced by a state file

This is the failure people miss. Once Guardrails 1 and 2 have taught the agent expand-contract, it gets enthusiastic about it. Our first "fixed" rename arrived as one PR titled "Complete expand-contract for display_name." It added the column and trigger, backfilled with a single `UPDATE users SET display_name = fullname`, switched the reads and dropped the old column, all in one deploy. The drop still lands in the same rollout as the code that stops reading the column, so v41 pods still break. On top of that, the backfill now holds row locks across the whole table. Same bug as before, with extra steps.

Phases need real time between them: a deploy that finishes, a backfill that runs, a quiet stretch in production. So we made the phase explicit and put it in git:

yaml
# migrations/inflight.yaml
# One entry per schema change in progress. Each PR may advance ONE entry by ONE phase.
# phase: expanded | backfilled | switched | contracted
changes:
  - id: users-fullname-to-display-name
    table: users
    from: fullname
    to: display_name
    phase: expanded
    owner: "@team-identity"
    opened: 2026-09-04
    contract_gate:
      source: pg_stat_statements
      quiet_days: 7

The check compares the file on `main` with the file in the PR. At most one entry may move, forward only, by at most one step. A `DROP COLUMN` is allowed only when the same PR moves the matching entry to `contracted`.

python
#!/usr/bin/env python3
# ci/check_inflight.py
# Fails any PR that moves a schema change forward more than one phase.
import os, re, subprocess, sys, yaml

PHASES = ["expanded", "backfilled", "switched", "contracted"]
DROP = re.compile(r"ALTER\s+TABLE\s+(\w+)\s+DROP\s+COLUMN\s+(?:IF\s+EXISTS\s+)?(\w+)", re.I)

def git(*args):
    return subprocess.run(["git", *args], capture_output=True, text=True).stdout

def load(ref):
    doc = yaml.safe_load(git("show", f"{ref}:migrations/inflight.yaml") or "{}") or {}
    return {c["id"]: c for c in doc.get("changes", [])}

base, head = load("origin/main"), load("HEAD")
errors, moved = [], []

for cid, c in head.items():
    before = PHASES.index(base[cid]["phase"]) if cid in base else -1
    after = PHASES.index(c["phase"])
    if after != before:
        moved.append((cid, c["phase"]))
    if after - before > 1:
        frm = PHASES[before] if before >= 0 else "new"
        errors.append(f"{cid}: {frm} -> {c['phase']} skips a phase. "
                      f"Next allowed phase is {PHASES[before + 1]}. Split this PR.")
    if after < before:
        errors.append(f"{cid}: phases only move forward ({base[cid]['phase']} -> {c['phase']}).")

for cid in base.keys() - head.keys():
    if base[cid]["phase"] != "contracted":
        errors.append(f"{cid}: removed from inflight.yaml before it was contracted.")

if len(moved) > 1:
    errors.append(f"This PR advances {len(moved)} changes. One change, one phase, per PR.")

contracting = {(head[cid]["table"], head[cid]["from"]) for cid, p in moved if p == "contracted"}
added = git("diff", "--name-only", "--diff-filter=A", "origin/main...HEAD", "--", "migrations/").split()
for path in (p for p in added if p.endswith(".sql")):
    for table, col in DROP.findall(open(path).read()):
        if (table, col) not in contracting:
            errors.append(f"{path}: drops {table}.{col}, but no inflight.yaml entry "
                          f"moves {table}.{col} to contracted in this PR.")

if os.environ.get("GITHUB_OUTPUT"):
    with open(os.environ["GITHUB_OUTPUT"], "a") as f:
        f.write("contracting=" + ",".join(cid for cid, p in moved if p == "contracted") + "\n")

if errors:
    print("inflight.yaml check failed:")
    print("\n".join(" - " + e for e in errors))
    sys.exit(1)

Why not let the N-1 job handle this too? It catches the drop. It can't catch expand, backfill and switch squashed together, because old code passes against that schema. The damage there comes from lock duration and replica lag, and a CI container with forty test rows will never show you either. The state file makes the sequence visible to a machine. It also gives the agent a clear next step every time it opens the repo.

The contract gate

"We switched the reads, so nothing reads the old column" is a claim about this repo. The contract PR has to prove it about production. We use `pg_stat_statements`. A daily cron job records, for each host, how many calls have come from statements that mention the old column. The gate passes only if that number hasn't moved on any host for `quiet_days`.

python
#!/usr/bin/env python3
# ci/column_reads.py snapshot        (daily cron, writes to telemetry DB)
# ci/column_reads.py gate <change-id> (CI, on PRs that move a change to contracted)
# env: PG_HOSTS="primary=postgres://...,replica-1=postgres://..."  TELEMETRY_DSN=...
import datetime, os, sys, yaml, psycopg

HOSTS = dict(h.split("=", 1) for h in os.environ["PG_HOSTS"].split(","))
CENTRAL = os.environ["TELEMETRY_DSN"]

def changes():
    with open("migrations/inflight.yaml") as f:
        return {c["id"]: c for c in yaml.safe_load(f)["changes"]}

def snapshot():
    today = datetime.date.today()
    with psycopg.connect(CENTRAL) as central:
        for c in (c for c in changes().values() if c["phase"] == "switched"):
            for host, dsn in HOSTS.items():
                with psycopg.connect(dsn) as conn:
                    calls = conn.execute(
                        "SELECT coalesce(sum(calls), 0) FROM pg_stat_statements "
                        "WHERE query ILIKE %s AND query ILIKE %s AND query NOT ILIKE '%%_sync_%%'",
                        (f"%{c['from']}%", f"%{c['table']}%"),
                    ).fetchone()[0]
                central.execute(
                    "INSERT INTO ops.column_read_snapshots (day, host, change_id, calls) "
                    "VALUES (%s, %s, %s, %s) ON CONFLICT (day, host, change_id) "
                    "DO UPDATE SET calls = EXCLUDED.calls",
                    (today, host, c["id"], calls))

def gate(change_id):
    c = changes()[change_id]
    quiet = c["contract_gate"]["quiet_days"]
    since = datetime.date.today() - datetime.timedelta(days=quiet)
    with psycopg.connect(CENTRAL) as central:
        rows = central.execute(
            "SELECT host, calls FROM ops.column_read_snapshots "
            "WHERE change_id = %s AND day >= %s ORDER BY host, day",
            (change_id, since)).fetchall()
    series = {}
    for host, calls in rows:
        series.setdefault(host, []).append(calls)
    problems = []
    for host in HOSTS:
        s = series.get(host, [])
        if len(s) < quiet + 1:
            problems.append(f"{host}: {len(s)} snapshots, need {quiet + 1}")
        elif any(b != a for a, b in zip(s, s[1:])):  # a drop means a stats reset: fail closed
            problems.append(f"{host}: reads of {c['table']}.{c['from']} changed: {s}")
    if problems:
        print(f"CONTRACT BLOCKED for {change_id}. Something still reads {c['table']}.{c['from']}:")
        print("\n".join(" - " + p for p in problems))
        print("Find the caller (query text in pg_stat_statements, other repos, cron, ETL), "
              "move it to the new column, and let the quiet window restart.")
        sys.exit(1)
    print(f"Contract gate passed: no new reads of {c['table']}.{c['from']} on {len(HOSTS)} hosts for {quiet} days.")

if __name__ == "__main__":
    snapshot() if sys.argv[1] == "snapshot" else gate(sys.argv[2])

Know the blind spots before you trust it. Every replica keeps its own `pg_stat_statements`, so snapshot all of them, since replicas are where reporting queries run. `SELECT *` never names the column, so a reader using it won't show up here. That's exactly why the reviewer checklist below asks a human about other readers. The text match is crude on purpose. A false positive blocks the contract and someone goes and looks at the query. A false negative drops a column that something still reads. If you already emit per-column read metrics from your ORM, use those instead. Either way, the gate needs network access to telemetry, so run it on a self-hosted runner or behind an internal endpoint.

Guardrail 4: the CLAUDE.md line

After the outage we did the obvious thing and wrote a long migrations section in CLAUDE.md. It covered lock timeouts, renames, NOT NULL, creating indexes concurrently, and batch sizes. Results got worse. The agent treated the list as a checklist to finish inside the current PR, and that's how we got the "complete expand-contract" PR. Rules collided: "keep each migration self-contained" plus "never rename in place" produced one migration that did everything. Half the rules duplicated the lint, and the two drifted apart. When they disagreed, the agent followed whichever one it had read last.

Now the lint owns everything a machine can check. CLAUDE.md holds the one idea lint can't express: the model of the world the agent should assume. It also points to where the authoritative feedback lives:

markdown
## Database migrations
Assume two app versions are live against every schema change; advance migrations exactly one phase per PR.
When `n-minus-1`, `migration-lint` or `inflight` fails, do what its output says. Never add ignore comments.

Guardrail 5: the reviewer checklist, production facts only

Once the machine handles compatibility, phase order and lint, the human reviewer has one job left: supply the facts that only exist in production and aren't in any checkout. If an item on your checklist could be a CI rule, make it one and cut it from the list.

markdown
### Migration review (production facts the agent can't know)
- [ ] Rows in target table today: ____ ; backfill batch size ____ ; sleep between batches ____
- [ ] lock_timeout ____ / statement_timeout ____ fit this table's write traffic at peak
- [ ] Replica lag budget ____ s, and the backfill pauses when lag exceeds it
- [ ] Readers outside this repo (other services, cron, ETL/BI, CDC consumers): ____
- [ ] Rollout window ≈ replicas × time-to-ready = ____ min (how long N-1 stays live)

Replaying the same rename

Three weeks after the outage we gave the agent the same ticket, worded the same way: "rename users.fullname to display_name."

Its first attempt was the same single-statement RENAME, green locally. It pushed, and two checks went red. `migration-lint` printed the renaming-column block with the expand SQL. `n-minus-1` failed with `column "fullname" does not exist` from the v41 test suite, plus the explanation of why. The agent pulled both logs, replaced the migration with ADD COLUMN and the sync trigger, created the inflight.yaml entry at `expanded`, and left application code alone. The second push was green. It took one correction loop and no human prompting.

Every later phase started with a one-line instruction: "advance users-fullname-to-display-name." The agent read inflight.yaml, saw the current phase and wrote the next one. Our part was filling in the checklist on PR 2 and reading the gate output on PR 4.

text
PR 1  expand    ADD COLUMN display_name + sync trigger      opened & merged day 1   2 agent loops
PR 2  backfill  batched UPDATE, 5k rows, pause on lag        merged day 2            reviewer cut batch size; ran ~40 min
PR 3  switch    app reads/writes display_name                 merged day 3            N-1 green: fullname still exists
      gate      7 quiet days, all hosts                       reset once on day 5     nightly CSV export in another repo still read fullname
PR 4  contract  DROP TRIGGER, DROP FUNCTION, DROP COLUMN      merged day 15           gate passed; inflight entry removed in PR 5

Fifteen days of elapsed time, instead of eleven minutes of 500s. Total human attention was maybe an hour, and most of it went to the export job, which nothing in this repo could have revealed. The gate caught it on day five, when the counter on one replica moved. Under the old setup, that cron job would have been our next outage.

The copy-paste kit

Here's everything in one place. The scripts from the sections above go in `ci/` and `lint/`, and this workflow wires them together.

text
# ===== .github/workflows/migrations.yml =====
name: migrations
on:
  pull_request:
    paths: ["migrations/**"]
jobs:
  migration-lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - run: pip install squawk-cli pyyaml
      - name: Lint new migrations (agent-readable output)
        run: |
          FILES=$(git diff --name-only --diff-filter=A origin/main...HEAD -- 'migrations/*.sql')
          [ -z "$FILES" ] || python ci/migration_lint.py $FILES
      - name: Ban unapproved ignore comments
        env:
          PR_NUMBER: ${{ github.event.pull_request.number }}
          AGENT_LOGIN: our-agent-bot
          GH_TOKEN: ${{ github.token }}
        run: bash ci/no_unapproved_ignores.sh

  inflight:
    runs-on: ubuntu-latest
    outputs:
      contracting: ${{ steps.check.outputs.contracting }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - run: pip install pyyaml
      - id: check
        run: python ci/check_inflight.py

  contract-gate:
    needs: inflight
    if: needs.inflight.outputs.contracting != ''
    runs-on: [self-hosted, prod-telemetry]
    steps:
      - uses: actions/checkout@v4
      - run: python ci/column_reads.py gate ${{ needs.inflight.outputs.contracting }}
        env:
          PG_HOSTS: ${{ secrets.PG_HOSTS_READONLY }}
          TELEMETRY_DSN: ${{ secrets.TELEMETRY_DSN }}

  # n-minus-1: paste the job from Guardrail 1 here

# ===== migrations/inflight.yaml =====
# Each PR may advance ONE entry by ONE phase: expanded | backfilled | switched | contracted
changes:
  - id: <table>-<old>-to-<new>
    table: <table>
    from: <old>
    to: <new>
    phase: expanded
    owner: "@<team>"
    opened: <YYYY-MM-DD>
    contract_gate: { source: pg_stat_statements, quiet_days: 7 }

# ===== CLAUDE.md =====
## Database migrations
Assume two app versions are live against every schema change; advance migrations exactly one phase per PR.
When `n-minus-1`, `migration-lint` or `inflight` fails, do what its output says. Never add ignore comments.

# ===== .github/pull_request_template.md (migration section) =====
### Migration review (production facts the agent can't know)
- [ ] Rows in target table today: ____ ; backfill batch size ____ ; sleep between batches ____
- [ ] lock_timeout ____ / statement_timeout ____ fit this table's write traffic at peak
- [ ] Replica lag budget ____ s, and the backfill pauses when lag exceeds it
- [ ] Readers outside this repo (other services, cron, ETL/BI, CDC consumers): ____
- [ ] Rollout window ≈ replicas × time-to-ready = ____ min (how long N-1 stays live)

# ===== cron (daily) =====
# 0 6 * * *  cd /srv/app && python ci/column_reads.py snapshot

None of this makes the agent smarter. It could already write a safe expand-contract sequence. It never had a reason to, because nothing in its loop looked like a rolling deploy. Make the agent's tests run under the conditions production actually has, and the safe sequence becomes the easiest path for it.

Discussion

Chat with Chi Cong (AI) about this article. Your conversation is private to you — you can publish a summary for others when you're done.

Ask me anything about “Agent-written database migrations” — trade-offs, decisions, or the story behind it.