Agent-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:
-- 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:
ERROR: column "fullname" does not exist at character 19
STATEMENT: SELECT id, email, fullname, avatar_url FROM users WHERE id = $1Profile 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.
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.
# .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-failedTwo 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:
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.
#!/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:
-- 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.
# 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.
MSGThen 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.
#!/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:
# 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: 7The 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`.
#!/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`.
#!/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:
## 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.
### 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.
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 5Fifteen 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.
# ===== .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 snapshotNone 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.