DEV Community

Casey Li
Casey Li

Posted on

Live Schema Changes Do Not Belong on Free Inference

Live schema change is a custody problem, not a prompting problem. Free inference can help a team phrase a migration plan, but it should not author the DDL that a runner applies, and it should not hold credentials for migrate up in any database that already stores user rows.

The public argument that models already write code better than most developers does not move that line. Fluency is a sampling process. A migration is a one-way contract with live data. Sampling can be retried and thrown away. Contracts cannot. A free endpoint that stalls, remaps, or drifts in quality mid-call is a poor signer of that contract.

A loading dock is the right picture. Scrap paper is fine for sketching how a crate should be packed. The stamp that lets the crate leave the building is a different instrument. Free-tier inference behaves like scrap paper. Capacity appears and disappears. Output is not a pinned binary. The same prompt can return a conservative ADD COLUMN on one call and a full table rewrite on the next. Stamping a production crate with that output is how a quiet outage is born.

The failure usually arrives dressed as convenience. A workflow asks a free endpoint for SQL that will make a Go struct match production, then a job executes the reply. The same endpoint is later asked whether that SQL is safe, which is a circular verdict rather than a review. A timeout retries the completion while the migrator is not idempotent, so two ALTER statements land. No model snapshot is stored next to the migration file, so a bad deploy cannot be bisected back to the author. The statement includes DROP, a narrowing cast, a column rename that will break readers still in flight, or a rewrite that takes an ACCESS EXCLUSIVE lock on a hot table.

None of those incidents mean the model is uniquely bad at SQL. They mean the pipeline mixed a pencil with a master key. Drafting a plan and applying a plan are different jobs. Free inference can hold the pencil. It should not hold the key.

The control that follows is blunt on purpose. Intent is classified with deterministic rules, not with another model. Asking a free endpoint whether a free endpoint should be allowed to apply DDL repeats the original mistake. If the path is schema apply, any provider marked free is refused. Review notes may still be generated. The apply client talks only to a migrator the team already runs in CI, with the file bytes hashed and a human ACK recorded.

The snippet below is a proposal. It has not been executed against a vendor and is not a claim about any hosted quota or model catalog.

from dataclasses import dataclass
from enum import Enum
import hashlib
import re

class ProviderClass(Enum):
    FREE = "free"
    PINNED = "pinned"
    LOCAL = "local"

DDL_APPLY = re.compile(
    r"\b(alter\s+table|drop\s+table|drop\s+column|create\s+index|create\s+table)\b",
    re.I,
)
DESTRUCTIVE = re.compile(
    r"\b(drop\s+table|drop\s+column|truncate|alter\s+column\s+\w+\s+type)\b",
    re.I,
)

class CustodyError(RuntimeError):
    pass

@dataclass(frozen=True)
class ApplyRequest:
    provider_class: ProviderClass
    sql: str
    human_ack: bool
    environment: str  # prod | staging | dev
    file_bytes: bytes

def migration_digest(file_bytes: bytes) -> str:
    return hashlib.sha256(file_bytes).hexdigest()

def assert_apply_allowed(req: ApplyRequest) -> None:
    if req.provider_class is ProviderClass.FREE:
        raise CustodyError("free inference cannot own DDL apply")
    if req.environment in {"prod", "staging"} and not req.human_ack:
        raise CustodyError("human ACK required outside disposable databases")
    if req.environment == "prod" and DESTRUCTIVE.search(req.sql):
        raise CustodyError("destructive DDL in prod needs an online schema changer")
    if DDL_APPLY.search(req.sql) and not req.file_bytes.strip():
        raise CustodyError("apply path requires hashed migration bytes, not a chat log")
Enter fullscreen mode Exit fullscreen mode

Closed tests should fail closed. Missing provider class is a refusal, not a default to free. A chat transcript is not an input. The file that is about to run has to be the same bytes that a reviewer signed.

import pytest

def test_free_provider_never_applies():
    req = ApplyRequest(
        provider_class=ProviderClass.FREE,
        sql="ALTER TABLE orders ADD COLUMN expires_at timestamptz;",
        human_ack=True,
        environment="prod",
        file_bytes=b"ALTER TABLE orders ADD COLUMN expires_at timestamptz;\n",
    )
    with pytest.raises(CustodyError, match="cannot own DDL apply"):
        assert_apply_allowed(req)

def test_prod_destructive_sql_is_refused_even_when_pinned():
    req = ApplyRequest(
        provider_class=ProviderClass.PINNED,
        sql="ALTER TABLE orders DROP COLUMN notes;",
        human_ack=True,
        environment="prod",
        file_bytes=b"ALTER TABLE orders DROP COLUMN notes;\n",
    )
    with pytest.raises(CustodyError, match="online schema changer"):
        assert_apply_allowed(req)

def test_digest_is_stable_for_the_same_bytes():
    payload = b"-- add expires_at\nALTER TABLE orders ADD COLUMN expires_at timestamptz;\n"
    assert migration_digest(payload) == migration_digest(payload)
Enter fullscreen mode Exit fullscreen mode

Commands around the gate should treat the migration file as the artifact and the model as optional commentary. Piping a completion into psql is the anti-pattern. Hashing the file, linting it, and applying only that file is the pattern.

# proposal workflow: the file is the contract, not the chat log
sha256sum migrations/20260917_add_expires_at.sql
sqlfluff lint migrations/20260917_add_expires_at.sql
atlas schema diff --from file://schema.hcl --to postgres://localhost:5432/app?sslmode=disable
psql --set ON_ERROR_STOP=1 -c "SELECT locktype, relation::regclass, mode FROM pg_locks WHERE NOT granted;"
# never: some_chat_cli | psql
Enter fullscreen mode Exit fullscreen mode

Better alternatives already exist and do not need a model in the apply loop. Expand-contract migrations keep old and new columns serving traffic until readers move, then drop the old column in a later, boring change. Online changers such as gh-ost or pt-online-schema-change avoid a long lock on a busy table. Flyway, Liquibase, or Atlas can record the exact bytes applied, which is a ledger a chat transcript cannot replace. If a model is used at all, it drafts a plan against staging data. It does not emit a statement that a bot will run.

Application-level rollouts are often the real need hiding inside a rushed DDL request. A new column that is only read behind a flag does not require a lock-heavy rewrite. A backfill that can run in chunks does not require a single chat-authored UPDATE. Those shapes keep the irreversible work small and scheduled, which is the opposite of a free completion that arrived in 800 milliseconds and looked confident.

Exit criteria should be written before the first incident, not after. Drafting on free inference stops when the change is not additive, when a rewrite needs a table lock, when rollback is not a forward migration, or when the data class is regulated. Planning stops there as well when a well-meaning intern might paste the output into an apply job. The exit is not a sharper prompt. The exit is taking apply credentials off that path and leaving them on the migrator the team already trusts.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. Free model access and a free server option are still reasonable for restating a dry-run log or drafting a rollback checklist in a sandbox, because those jobs tolerate stalls and do not mutate rows. They are the wrong place to keep psql credentials or an apply token. A team that wants a separate machine for that write-up can keep the sandbox on the free server option, then apply from the existing migrator with hashed files.

The gate has limits. It does not detect semantic damage in an additive column that stores the wrong unit. It does not replace expand-contract discipline, lock measurement on a production-sized copy, or a human who understands the table. Platform groups that already run a schema service should not insert a chat provider into that service in order to look current. Teams without migrations as code should not treat a refusal rule as a substitute for review. Disposable local databases are out of scope. The rule is for data that is shared with users.

Keep the scrap paper. Take the stamp away from the free endpoint. Live schema change belongs to a hashed file, a migrator the team can replay, and a person who can still say no after the model has finished talking.

Top comments (0)