Last Friday a product manager pinged me before standup and asked for a refund reason code. I watched a coding agent dump an ALTER TABLE statement into the pull request like it already owned production. Staging looked green, because that database sits alone with nobody racing the migration during lunch. Have you ever shipped a chat-generated schema change and felt your stomach drop in the deploy window?
The public argument this week pretends the crisis is whether models already code better than most working developers. That framing is a distraction from the layer that actually fails when a user request hits a live stack. I think vibe coding is fine for sketches, and calling those sketches engineering is how outages get a polite commit hash. The first broken handoff is not the model; it is schema authority leaking into a chat window.
I am taking a blunt position here, and I will not sand it down for comfort. A free coding loop is a prototype furnace, not a migration process, and treating those as the same job is how you burn a weekend. If the user action is “add this field before Friday traffic,” the first layer that fails is persistence under concurrency, not the quality of the generated SQL. Why do we keep measuring the chat when the journal is empty?
Picture the chat like a loading dock with no bills of lading. Anyone can wheel a crate onto the truck, and the driver still leaves on time. Your production database is that truck, and an unsourced ALTER is a crate with no seal, no destination, and no way to prove it was already applied. I want the model to propose a crate label. I do not want the model to start the engine.
The working path I keep reaching for is small and slightly boring, which is the point. Freeze a schema snapshot, ask the model only for a plan file, validate that plan against the snapshot, and apply through an idempotent journal with a human or pipeline as the only writer. The chat never receives database credentials, and the apply job never receives a prose paragraph. If that sounds slower than pasting SQL, ask yourself what you will replay at two in the morning.
Here is a proposed contract I would drop into a service repo before any model is allowed near write authority. It is illustrative, not a claim about a specific production outage, and you should treat the snippet as a starting shape rather than a blessed library.
# apply_journal.py — proposed shape, not a published package
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Literal
PlanOp = Literal["add_column"]
@dataclass(frozen=True)
class ColumnPlan:
table: str
name: str
sql_type: str
nullable: bool
@dataclass(frozen=True)
class SchemaPlan:
plan_id: str
op: PlanOp
column: ColumnPlan
based_on_schema_sha: str
def digest(self) -> str:
payload = json.dumps({
"plan_id": self.plan_id,
"op": self.op,
"table": self.column.table,
"name": self.column.name,
"sql_type": self.column.sql_type,
"nullable": self.column.nullable,
"based_on_schema_sha": self.based_on_schema_sha,
}, sort_keys=True).encode()
return hashlib.sha256(payload).hexdigest()
ALLOWED_TYPES = {"TEXT", "INTEGER", "BOOLEAN", "TIMESTAMPTZ"}
ALLOWED_TABLES = {"refunds", "refund_events"}
class PlanRejected(Exception):
pass
def validate_plan(plan: SchemaPlan, live_schema_sha: str, existing_columns: set[str]) -> None:
if plan.based_on_schema_sha != live_schema_sha:
raise PlanRejected("plan_stale_schema")
if plan.op != "add_column":
raise PlanRejected("op_not_allowed")
if plan.column.table not in ALLOWED_TABLES:
raise PlanRejected("table_not_in_contract")
if plan.column.sql_type not in ALLOWED_TYPES:
raise PlanRejected("type_not_in_contract")
if plan.column.name in existing_columns:
return # idempotent no-op, not an error
if not plan.column.name.isidentifier() or plan.column.name.startswith("_"):
raise PlanRejected("name_not_safe")
Notice what the model is not doing in that file. It is not choosing a table outside the allowlist, and it is not inventing a vendor type that your ORM cannot round-trip. It is not concatenating SQL from a paragraph that sounded confident in the chat. Would you let a contractor rewire your kitchen from a voice note, or would you make them mark the joists first?
The apply side is where teams usually get lazy, because the happy path looks like a single statement. I want a journal row that records the plan digest, the actor, and a terminal state the job can safely retry. Network retries will happen. Process crashes will happen. Chat windows will resubmit the same “yes, apply it” because somebody double-clicked. If your apply is not keyed by digest, you do not have a workflow. You have a vibe.
# journal_apply.py — proposed apply path
class JournalConflict(Exception):
pass
TERMINAL = {"applied", "noop"}
def apply_add_column(conn, plan: SchemaPlan, actor: str) -> str:
digest = plan.digest()
existing = conn.fetch_journal(digest)
if existing and existing["state"] in TERMINAL:
return existing["state"]
if existing and existing["state"] == "applying":
raise JournalConflict("apply_in_flight")
conn.insert_journal({
"digest": digest,
"plan_id": plan.plan_id,
"actor": actor,
"state": "applying",
"started_at": datetime.now(timezone.utc).isoformat(),
})
cols = conn.list_columns(plan.column.table)
if plan.column.name in cols:
conn.finish_journal(digest, "noop")
return "noop"
null_sql = "NULL" if plan.column.nullable else "NOT NULL"
# Identifier already validated; still parameterize nothing into names.
conn.execute(
f'ALTER TABLE {plan.column.table} '
f'ADD COLUMN {plan.column.name} {plan.column.sql_type} {null_sql}'
)
conn.finish_journal(digest, "applied")
return "applied"
That f-string still makes me itch, which is useful discomfort. Column names cannot be bound as values in most SQL dialects, so the real control is the validator, not a prettier driver call. If you skip validation and keep the f-string, you did not build a contract. You built a footgun with logging. Can you point to the exact function that would refuse a plan for refunds; DROP TABLE on a tired Friday?
I also want a cross-layer failure test that does not require a model in the loop, because the model is the noisiest part of the system. Freeze a schema SHA, submit a plan built on a different SHA, and assert the API answers 409 with plan_stale_schema. Submit the same digest twice and assert the second call returns noop without a second ALTER. Submit a second apply while the first is applying and assert 409 with apply_in_flight. If those three states are not tests, they will become incident comments.
# test_apply_journal.py — proposed pytest sketch
def test_stale_plan_is_rejected(client, live_schema_sha):
plan = {
"plan_id": "refund-reason-code-1",
"op": "add_column",
"column": {
"table": "refunds",
"name": "reason_code",
"sql_type": "TEXT",
"nullable": True,
},
"based_on_schema_sha": "deadbeef",
}
res = client.post("/schema/plans/validate", json=plan)
assert res.status_code == 409
assert res.json()["code"] == "plan_stale_schema"
def test_replay_does_not_alter_twice(conn, valid_plan):
first = apply_add_column(conn, valid_plan, actor="pipeline")
second = apply_add_column(conn, valid_plan, actor="pipeline")
assert first in {"applied", "noop"}
assert second == "noop"
assert conn.list_columns("refunds").count("reason_code") == 1
Where does a free coding environment belong in this story, if it belongs at all? I will use one for the propose step on a scratch server, the same way I use a whiteboard before I touch a load-bearing wall. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is an open-source project with free model access and a free server option, which is enough to generate plan files and run the validator without parking the model on the apply path. That is the whole relevant seam. The product does not become the journal, and the journal does not become a pitch.
I still would not let that free loop hold production credentials, and I would not let it mark a journal row applied. The cheap environment is for proposing and failing closed. The expensive environment is for the pipeline identity that already has change windows, backups, and a human who can say no. If your team cannot describe that split in one sentence, you are not productionizing a prototype. You are decorating a risk.
This approach has limits, and they are not subtle. Do not use it as a substitute for a real migrator if you already have expand-and-contract discipline, strong review, and lock-step deploys that you trust. Do not use it on regulated data if your journal would store payloads you are not allowed to retain. Do not use it if you need multi-statement migrations, check constraints, or backfills that touch existing rows, because this slice only adds nullable columns on an allowlist. People chasing a fully autonomous schema agent should stop here and keep the model read-only.
Cost and maintainability sit on the same hinge. A plan file is cheap to store and cheap to diff, while a chat transcript is expensive to audit and almost impossible to replay. A journal row gives you a unique digest you can show in an incident channel without quoting seven screens of model rambling. The tradeoff is obvious to me: you give up the feeling of speed so you can keep the feeling of sleep.
If you want a reusable pass before you grant write authority, walk the path once on a throwaway database. Snapshot the schema, emit a plan, reject a stale SHA, apply once, replay once, and only then wire a UI button that never talks to the model. Keep the button talking to the journal. If you try this on a scratch box, including a free MonkeyCode server if you need a sandbox, send me the failure state you actually got, not a vibe.
Which layer handoff is least stable in your stack when a “simple field” request shows up on Friday? I want a concrete response code or journal state, not a speech about how good the model felt that afternoon.
Top comments (0)