DEV Community

kongkong
kongkong

Posted on

Build Dual-Read Before Giving the Agent Drop Authority

When the Slack ping landed at 4:51 on a Thursday, the request sounded almost reasonable and small. Could the coding agent just rename status to account_state so billing would stop lying to finance? I watched the generated diff compile, the laptop tests pass, and the settings page render the new field as if Postgres had already grown up. Have you ever shipped a rename that only existed inside the process that ran the migration?

That is the failure I keep seeing now that generation is cheap and confidence is free. The agent assumes the write it proposed already happened on every replica, cache, and phone. Replicas, CDNs, and the billing export job do not share that optimism, and they will not start sharing it because a prompt was sure. I am tired of treating a live schema change like a local refactor just because one model can emit Alembic and a TypeScript type in one breath.

Here is my position, and I am not sanding it down for politeness. You should not let an agent drop, rename, or rewrite a column until a dual-read path has survived production traffic. If the generated migration contains RENAME COLUMN or DROP COLUMN against a live API, it is not a migration. It is a coordinated outage with extra steps, and the first layer that fails is almost never the model call. It is the serializer still emitting the old key into a cache that will happily serve mixed JSON for the next ninety seconds.

Think about a bridge crew that dynamites the old span while the new deck is still a drawing on a tablet. Why do we let a coding agent cut the old column the moment the new model file exists on disk? Because the prototype loop rewards a green local test, and local tests do not have rolling deploys, read replicas, or a React Query key the same agent also invented. Cheap code moves the debt out of functions and into unsigned contracts between UI, API, and storage. That is not architecture being precious. That is the only way a rename fails closed instead of failing in support tickets.

The working path I want is boring on purpose, which is exactly why generated code tries to skip it. Freeze a response contract that can carry both field names without lying about which one is canonical yet. Serve reads from both columns while you backfill. Write only to the new column after the expand is deployed. Delete the old column only after every caller, including the forgotten CSV export, has stopped asking for it. An agent may propose the expand. It does not get drop authority until dual-read tests say the old shape still round-trips.

I keep that rule in code, not in a prompt appendix nobody enforces at apply time. The read model dual-reads, the write model refuses the old column, and the apply endpoint rejects forbidden operations with 409 instead of a cheerful 200.

# proposed pattern: dual-read serializer + forbidden apply ops
# labeled example — run it as a contract test, not as folklore
from typing import Any, Literal

from pydantic import BaseModel, Field, model_validator

class AccountStateRead(BaseModel):
    id: str
    status: str | None = None          # old wire key, still served
    account_state: str | None = None   # new wire key
    canonical: Literal["status", "account_state"]

    @model_validator(mode="after")
    def both_shapes_or_honest_null(self) -> "AccountStateRead":
        if self.status is None and self.account_state is None:
            raise ValueError("dual-read produced no value on either column")
        if self.canonical == "account_state" and self.account_state is None:
            raise ValueError("canonical column missing while old column still exists")
        return self


class SchemaPlan(BaseModel):
    table: str
    old_column: str
    new_column: str
    operations: list[str] = Field(default_factory=list)
    dual_read_proven: bool = False

    @model_validator(mode="after")
    def no_drop_before_dual_read(self) -> "SchemaPlan":
        forbidden = {"DROP COLUMN", "RENAME COLUMN"}
        hits = [op for op in self.operations if op.upper() in forbidden]
        if hits and not self.dual_read_proven:
            raise ValueError(f"drop/rename before dual-read: {hits}")
        return self
Enter fullscreen mode Exit fullscreen mode

The API should make that failure a status code, not a log line a human might miss during a Friday rollout. I want the agent to be able to POST a plan, and I want apply to be a different verb with a different permission. If you give the same bearer token to plan and apply, you did not build a gate. You built a comment.

# proposed FastAPI slice — plan is readable, apply is capability-gated
from fastapi import FastAPI, Header, HTTPException
from fastapi.testclient import TestClient

app = FastAPI()
PROVEN = {"accounts.status->account_state": False}

@app.post("/schema/plans")
def create_plan(plan: SchemaPlan):
    try:
        plan.dual_read_proven = PROVEN.get(
            f"{plan.table}.{plan.old_column}->{plan.new_column}", False
        )
        SchemaPlan.model_validate(plan.model_dump())
    except ValueError as exc:
        raise HTTPException(status_code=409, detail=str(exc)) from exc
    return {"id": "plan_123", "status": "accepted", "apply": False}

@app.post("/schema/plans/{plan_id}/apply")
def apply_plan(plan_id: str, x_capability: str | None = Header(default=None)):
    if x_capability != "schema:apply":
        raise HTTPException(status_code=403, detail="plan cannot apply itself")
    raise HTTPException(status_code=409, detail="dual-read not proven")
Enter fullscreen mode Exit fullscreen mode

Does that feel slow compared with letting the agent run Alembic on the primary? Good. Speed is how the bad rename got into staging last time. The cross-layer test I actually care about is not “did the model emit SQL.” It is whether the UI can still hydrate from the old key while a replica is one statement behind, and whether apply stays 403 or 409 when the capability header is missing. If your only test is a unit test on the generator, you tested the printer, not the press.

def load_account(row: dict[str, Any]) -> AccountStateRead:
    new = row.get("account_state")
    old = row.get("status")
    return AccountStateRead(
        id=str(row["id"]),
        status=old if old is not None else new,
        account_state=new if new is not None else old,
        canonical="account_state" if new is not None else "status",
    )


def test_dual_read_survives_replica_lag():
    primary = {"id": "u1", "status": None, "account_state": "active"}
    replica = {"id": "u1", "status": "active", "account_state": None}  # lag
    assert load_account(primary).account_state == "active"
    assert load_account(replica).status == "active"


def test_apply_without_capability_is_403():
    client = TestClient(app)
    plan = {
        "table": "accounts",
        "old_column": "status",
        "new_column": "account_state",
        "operations": ["ADD COLUMN"],
    }
    assert client.post("/schema/plans", json=plan).status_code == 200
    denied = client.post("/schema/plans/plan_123/apply")
    assert denied.status_code == 403


def test_drop_before_dual_read_is_409():
    client = TestClient(app)
    plan = {
        "table": "accounts",
        "old_column": "status",
        "new_column": "account_state",
        "operations": ["DROP COLUMN"],
    }
    res = client.post("/schema/plans", json=plan)
    assert res.status_code == 409
    assert "dual-read" in res.json()["detail"]
Enter fullscreen mode Exit fullscreen mode

Run that file against a real HTTP process, not against a mocked router in a notebook. I use a scratch box so the 409 is an actual wire response the UI must handle, because optimistic caches are where generated frontends hide the failure. Disclosure: This article was prepared as part of MonkeyCode's product outreach. When I need a disposable model to propose nasty migrations and a free server to prove the API rejects them, MonkeyCode's free model access and free server option are the scratch lane I actually use, not a place I apply DDL.

The trick is the job you give the free tier. Ask it to generate the migration a tired Friday agent would generate, including RENAME COLUMN status TO account_state in one shot. Then POST that plan at your contract endpoint and record the status code. If you get 200, your gate is theater. If you get 409 with a detail the UI can show, you just spent free tokens on the only rehearsal that matters: making the agent stop assuming the schema it invented already exists everywhere.

# proposed rehearsal — generate a bad plan, then prove the server refuses it
python -m pip install fastapi pydantic uvicorn pytest httpx
pytest -q test_schema_contract.py
uvicorn app:app --port 8080 &

curl -sS -D - http://127.0.0.1:8080/schema/plans \
  -H 'content-type: application/json' \
  -d '{"table":"accounts","old_column":"status","new_column":"account_state","operations":["DROP COLUMN"]}'

# expect HTTP/1.1 409 Conflict, not a migration id
Enter fullscreen mode Exit fullscreen mode

Production still has caveats this pattern will not hug away. Dual-read doubles the chance you leak an old value after a new write if you forget to write-through both during the expand, so I write new-only and backfill in batches with a lag check. JSON documents inside jsonb columns will not be saved by a table-level dual-read, and mobile clients that pin an old protobuf will ignore your pretty serializer. Feature flags that only hide a button do not count as authorization, because the agent will call apply without the button. And if your ORM session is shared between the request identity and the tool runner, the capability header is a costume.

Who should not use this? Do not drag expand and contract into a throwaway prototype with one user and a database you can drop at lunch. Do not use a free scratch server as your production migrator, and do not treat a passing local pytest as proof that replicas converged. If you have no way to version the wire contract, this whole argument collapses into comments in a pull request. Comments are not a contract. Status codes are.

I still want a short checklist you can paste beside the generated Alembic file before anyone types apply. Dual-read serializer emits both keys and fails if both are empty. Plan endpoint accepts expand operations and returns 409 on drop or rename while dual_read_proven is false. Apply requires a capability the planner does not hold, and missing it is 403. One test sends the old UI payload against a lagged replica shape. One test hits a real HTTP port, not a mocked app object. If any box is missing, the agent does not get drop authority, no matter how clean the diff looks.

So I will keep arguing the same unpopular thing when the next rename shows up in Slack at 4:51. Cheap generation did not make schema design optional. It made unsigned handoffs cheaper to ship, which is worse. Which layer handoff is least stable in your app right now, and what status code does it return when the agent assumes the new column already exists—200 with mixed JSON, 403 on apply, or a quiet 500 after the replica catches up?

Top comments (0)