A schema cutover fails closed when a language model is allowed to choose the SQL. Expand, backfill, and contract are three different authorities, and only the first can tolerate a draft. The contract phase deletes a column, a constraint, or a code path that production might still touch. That deletion belongs in a reviewed file on the primary's migration runner, not in a completion returned by a free inference endpoint.
Teams reach for an assistant because the expand phase looks like prose. A column is added as nullable, a dual-write is sketched, and a backfill is described in sentences that resemble a runbook. The analogy is a bridge deck: temporary beams can be discussed with a colleague who has not signed the load rating. The moment the old beam is cut, the signature has to sit on a drawing that outlives the conversation.
Free inference is the conversation. The drawing is the ledger. This piece is a when-not-to-use field guide for that boundary, not a scorecard. It does not report a model name, a token quota, a region, a hardware shape, or an uptime figure, because those values move and a runbook that hardcodes them is already stale.
What a free assistant may touch
An assistant may narrate a failing check, name the phase a statement belongs to, and propose comments on the expand migration. MonkeyCode's free model access can draft that narration, and its free server option can host the rehearsal away from production. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Neither capability is the migration runner. The runner is CI, plus a database role the prompt cannot reach. Power the assistant down and the contract file should still apply, in the same order, with the same checksum. If the deploy waits on a completion, the free path has become a control plane, and the price of the call does not change that fact.
A clean story that still drops the wrong beam
Consider an orders table moving from a decimal total to total_cents. Expand adds the column, application code dual-writes, a backfill copies old rows, and only then does contract drop the original. Nothing in that sequence is exotic, and nothing in it requires a model.
The break is temporal. An old worker revision still selects total, a summarized dashboard says the new column is populated, and a short completion gets piped into psql. The deploy goes green while checkout fails on the straggler revision. The illustration is a composite, not a named outage, and it needs no benchmark to justify a refusal.
Red flags, then the duller path
The dangerous setups share a silhouette. A production DSN or cloud credential sits in the prompt, model text is piped into psql, one deploy both adds a column and drops its predecessor, a backfill is closed because a completion called the table small, or a free server holds the only copy of the files. Each of those means the ledger has already left git.
The alternative is intentionally dull. SQL stays in the repository, a human applies the contract phase from a reviewed tag, and the assistant sees only a redacted plan whose explanation the gate does not trust. Row counts come from a query run by the migration role, not from a sentence in a transcript.
Version details stay in CI as well. On PostgreSQL 11 and later, adding a nullable column, or a column with a constant default, is often a catalog-only change, but that behavior is version-specific and has to be confirmed against the current ALTER TABLE notes before anyone relies on it (https://www.postgresql.org/docs/current/sql-altertable.html). A rewrite that substitutes a function default, or that bundles the drop into the same step as code that still reads the old name, gives away the safety the catalog change had bought.
A gate that refuses assistant-owned contract SQL
The Python below is a proposal. It was not executed while this article was drafted, and it is not a migrator. It is a tripwire a CI job can run with the standard library: exit 0 means a later apply job may read the ledger with no model in the loop, and exit 2 means fail closed.
#!/usr/bin/env python3
"""Proposal: refuse contract-phase SQL that an assistant claims to own.
Unexecuted example. Stdlib only. Exit 0 means a later apply job may
read the ledger without any model. Exit 2 means fail closed.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
CONTRACT_MARKERS = (
'drop column',
'drop table',
'drop constraint',
'alter column',
'set not null',
'rename column',
)
def normalize(sql: str) -> str:
return ' '.join(sql.lower().split())
def is_contract(sql: str) -> bool:
text = normalize(sql)
return any(marker in text for marker in CONTRACT_MARKERS)
def evaluate(plan: dict) -> list[str]:
errors: list[str] = []
steps = plan.get('steps', [])
phases = [step.get('phase') for step in steps]
if phases.count('expand') < 1 or phases.count('contract') < 1:
errors.append('plan must split expand and contract into separate steps')
if 'backfill' not in phases:
errors.append('plan has no backfill step between expand and contract')
seen_expand = False
for index, step in enumerate(steps):
phase = step.get('phase')
owner = step.get('owner')
sql = step.get('sql', '')
blob = json.dumps(step).lower()
if phase == 'expand':
seen_expand = True
if owner == 'assistant':
errors.append(
f'step {index} is assistant-owned; ledger steps must be human-owned'
)
if phase == 'contract' and not seen_expand:
errors.append(f'step {index} contracts before any expand')
if phase != 'contract' and is_contract(sql):
errors.append(
f'step {index} hides contract SQL outside the contract phase'
)
if phase == 'contract' and step.get('apply') != 'ci-primary':
errors.append(
f'step {index} must apply on ci-primary, not on a model host'
)
if 'dsn' in step or 'password' in blob or 'secret' in blob:
errors.append(f'step {index} carries a credential field')
return errors
def main(argv: list[str]) -> int:
if len(argv) != 2:
print('usage: cutover_gate.py plan.json', file=sys.stderr)
return 2
plan = json.loads(Path(argv[1]).read_text())
errors = evaluate(plan)
if errors:
for err in errors:
print(f'refuse: {err}')
return 2
print('accept: contract phase is local, human-owned, and ordered')
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv))
A hostile fixture should trip that proposal. The assistant owns the contract step, the apply target is a model host, and no backfill sits between expand and contract.
{
"steps": [
{
"phase": "expand",
"owner": "human",
"apply": "ci-primary",
"sql": "alter table orders add column total_cents bigint"
},
{
"phase": "contract",
"owner": "assistant",
"apply": "model-host",
"sql": "alter table orders drop column total"
}
]
}
Read off the branches, the expected result of this unexecuted proposal is a refusal and exit code 2, and any one of those three defects is enough. A clean plan inverts them: every step is human-owned, contract applies on ci-primary, and a backfill step separates the schema phases. The accept line still does not execute SQL; it only tells CI that a separate job may read the file.
Commands stay on the workstation or in CI. They do not take a production URL, and they do not call an inference API.
python3 -m py_compile cutover_gate.py
python3 cutover_gate.py fixtures/assistant_owns_contract.json
echo "gate_exit=$?"
sha256sum migrations/0042_contract.sql | tee contract.sha256
Evidence for the backfill is a query saved beside the tag, not a paragraph in a chat log. The migration role runs it, and the assistant never sees the connection string.
psql "$MIGRATE_URL" -v ON_ERROR_STOP=1 -c "select count(*) from orders where total_cents is null;"
The apply step comes later, and only after the gate has exited 0 and a reviewer has signed the tag. The URL belongs in the CI secret store.
psql "$MIGRATE_URL" -v ON_ERROR_STOP=1 -f migrations/0042_contract.sql
That URL does not belong in a chat transcript, in a free-server image baked for a demo, or in a log line an assistant is later asked to summarize. If the rehearsal host disappears overnight, the checksum file in git is still the source of truth.
How the rehearsal actually runs
The workflow is a sequence of local artifacts, not a chat. The plan file and the SQL land in git, the gate refuses the hostile fixture, and a checksum is stored beside the contract file. Only after that does CI, using a secret the assistant never receives, run the null-count query and, on a signed tag, apply the contract file. A free server can repeat the gate and the fixture for a teammate who has no database role; it never becomes the place the apply job lives.
When to walk away
The free path is finished as soon as the model emits a statement the gate classifies as contract. It is also finished when the prompt would need a DSN to be more helpful, when the free server holds the only copy of the migration files, or when the deploy cannot complete while the assistant is rate-limited, revoked, or simply not called. A backfill count that arrives as prose belongs to the same exit. The query replaces the completion, and the completion is not retried.
Some teams should not pick up the gate at all. A solo prototype with no production data does not need the ceremony, and adding it is theater. A group that wants a model to generate and apply destructive SQL will fight the markers on every merge, and weakening those markers until the script always accepts is worse than skipping it. A regulated change process that needs a validated evidence pack should keep the assistant out of the record, because retention, region, and logging on a free tier are product settings, not an audit exhibit.
The marker list is narrow on purpose. It will miss clever SQL, dynamic SQL, and syntax that does not resemble the six substrings, and it will also false-positive on a comment that merely mentions a drop. MySQL and online schema-change tools do not share PostgreSQL's transactional DDL habits, so a green run on one engine proves nothing about another. Linters in the strong-migrations family already refuse many destructive statements inside application repos, and that class of check remains the authority; an assistant may only translate a lint failure into a comment, and if the free assistant is busy or gone, the comment is skipped.
Expand notes can be drafted wherever a free model is convenient. The old column is cut only from a human-owned file that CI can apply with the assistant powered off. Readers who already keep a spare server and a free model nearby can point them at the hostile fixture and keep the session that returns a refusal; that rehearsal is the only use this boundary has for the free path.
Top comments (0)