Last Tuesday a product manager asked for ticket notes, and the coding panel spat out a complete POST handler before lunch. The demo path looked perfect in a notebook session, because every request ran as a local superuser against an empty database. Then a real help-desk agent tried the same write, and the first failure was not the model. It was a missing migration, a missing role check, and a 500 that told the user nothing useful.
I keep seeing this pattern in the current agents-will-write-the-feature conversation, and I think the framing is backwards. Whether a model can emit plausible FastAPI is almost uninteresting now, because emitting a handler is the easy part. The real question is whether your application can refuse a confident write until a read-only slice has proven auth and persistence. If you cannot name the status code that should come back, you do not have a feature, only a vibe.
So here is the opinion I will defend with a working path instead of another architecture diagram. Do not let an AI coding loop open POST, PATCH, or schema apply until a GET slice exists that fails closed. Use a cheap model and a disposable server for that rehearsal if you want, but keep write authority behind a plan file. I will walk a ticket-notes change through that gate, including the commands I actually run.
The user action stays small on purpose, because a tiny request still crosses every production layer you care about. An authenticated agent opens a ticket, expects to read notes, and only later wants to append one. That request should die at the API with 401, 403, or 404 long before it dies inside generated SQL. When I skip this order, the model helpfully creates the table during the request, and production traffic learns about the lock the hard way.
I encode the request as an intent record, not as another chat transcript that nobody can hash. The file is boring JSON, which is exactly the point, because later jobs can refuse a mutated copy. write_allowed stays false on purpose, and that flag is the whole architecture in one field. The model may propose a handler, a migration, and even a test name, but it does not flip the flag.
{
"intent_id": "intent_ticket_notes_01",
"actor_role": "agent",
"resource": "ticket",
"resource_id": "TCK-1042",
"action": "read_notes",
"write_action": "add_note",
"write_allowed": false
}
A human or a pipeline with a signed checklist flips write_allowed after the read-only slice returns a known matrix. I ask the model for a plan file, never for a patch against main, because patches hide the mutation switch. The contract stays tiny so a free model can fill it without inventing a second ORM. If the model returns enabled: true on the write route, I treat that plan as failed output.
{
"plan_id": "plan_ticket_notes_01",
"intent_id": "intent_ticket_notes_01",
"migration": {
"name": "add_ticket_notes",
"sql": "ALTER TABLE tickets ADD COLUMN notes TEXT NOT NULL DEFAULT ''"
},
"read_route": {
"method": "GET",
"path": "/tickets/{ticket_id}/notes",
"auth": "bearer",
"roles": ["agent", "admin"]
},
"write_route": {
"method": "POST",
"path": "/tickets/{ticket_id}/notes",
"auth": "bearer",
"roles": ["agent"],
"enabled": false
}
}
Why would we trust a generator that cannot keep its own hands off the mutation switch in its own plan? I hash the file before anyone talks about code, because a rewritten plan is a different change. The commands below are the whole review surface for that step, and they belong in CI.
sha256sum plan_ticket_notes_01.json
python - <<'PY'
import json
plan = json.load(open("plan_ticket_notes_01.json"))
assert plan["intent_id"] == "intent_ticket_notes_01"
assert plan["write_route"]["enabled"] is False
print("plan_ok_write_disabled")
PY
The application code then implements GET only, and every miss fails closed on purpose. This is the slice I run on a disposable server before anyone talks about shipping POST. I collapse missing tickets and cross-org tickets into 404, because a 403 on another tenant identifier leaks existence. Can you see how the AI-wrote-a-complete-CRUD-module story skips that tenancy decision entirely?
# notes_read.py — worked example for the read-only slice
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
app = FastAPI()
TICKETS = {"TCK-1042": {"org_id": "org_9", "notes": "Customer heard hold music."}}
ROLES = {
"agent_token": ("agent", "org_9"),
"admin_token": ("admin", "org_9"),
"other_token": ("agent", "org_other"),
}
class NotesOut(BaseModel):
ticket_id: str
notes: str
def actor(authorization: str | None):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="missing_token")
token = authorization.removeprefix("Bearer ").strip()
if token not in ROLES:
raise HTTPException(status_code=401, detail="bad_token")
return ROLES[token]
@app.get("/tickets/{ticket_id}/notes", response_model=NotesOut)
def read_notes(ticket_id: str, authorization: str | None = Header(default=None)):
role, org_id = actor(authorization)
ticket = TICKETS.get(ticket_id)
if ticket is None or ticket["org_id"] != org_id:
raise HTTPException(status_code=404, detail="ticket_not_found")
if role not in {"agent", "admin"}:
raise HTTPException(status_code=403, detail="role_forbidden")
return NotesOut(ticket_id=ticket_id, notes=ticket["notes"])
I have watched generated handlers return 403 for foreign ids with total confidence, then call the leak a security feature. The rehearsal host should be something you can delete, not a shared staging database that still holds yesterday's customer rows. I run the slice locally first, then on a free server when I want the same curl matrix to survive a cold machine. The four commands below are not setup theater; they are the merge gate.
uvicorn notes_read:app --host 127.0.0.1 --port 8088
curl -i http://127.0.0.1:8088/tickets/TCK-1042/notes
curl -i -H "Authorization: Bearer nope" http://127.0.0.1:8088/tickets/TCK-1042/notes
curl -i -H "Authorization: Bearer other_token" http://127.0.0.1:8088/tickets/TCK-1042/notes
curl -i -H "Authorization: Bearer agent_token" http://127.0.0.1:8088/tickets/TCK-1042/notes
Those four curls are the feature, not a prelude to the feature, and I will die on that hill. I want 401, 401, 404, and 200 in that order before I even ask the model for POST. If a free model helped draft the handler, this matrix is still the merge gate, because typing speed is not correctness. Write authority stays behind a second job that reads the plan checksum and only then mounts the router.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. I use MonkeyCode as an open-source project with free model access and a free server option when that rehearsal loop needs to leave my laptop. I do not treat that host as a system of record, and I do not pretend a free server replaces SSO, backups, or a secrets manager. The value is repeating the same four curls against a clean process, not collecting a new production dependency.
# notes_write.py — present in the repo, mounted only after the plan flip
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel, Field
write_router = APIRouter()
SEEN: set[tuple[str, str]] = set()
class NoteIn(BaseModel):
body: str = Field(min_length=1, max_length=2000)
idempotency_key: str = Field(min_length=8, max_length=64)
def mount_write_routes(app, plan):
if not plan["write_route"]["enabled"]:
return
app.include_router(write_router)
@write_router.post("/tickets/{ticket_id}/notes")
def add_note(ticket_id: str, payload: NoteIn, authorization: str | None = Header(default=None)):
role, org_id = actor(authorization)
if role != "agent":
raise HTTPException(status_code=403, detail="role_forbidden")
ticket = TICKETS.get(ticket_id)
if ticket is None or ticket["org_id"] != org_id:
raise HTTPException(status_code=404, detail="ticket_not_found")
key = (ticket_id, payload.idempotency_key)
if key in SEEN:
return {"ticket_id": ticket_id, "notes": ticket["notes"], "replayed": True}
ticket["notes"] = payload.body
SEEN.add(key)
return {"ticket_id": ticket_id, "notes": ticket["notes"], "replayed": False}
Idempotency is not optional once retries exist, and model-generated POST handlers forget that constantly in otherwise pretty code. Network clients retry, humans double click, and mobile apps replay a buffered submit when the radio wakes. If your apply path cannot survive that, the model did not finish the feature, no matter how clean the names look. I keep a pytest file that encodes the matrix so a future model run cannot simplify the 404 into a 403.
# test_notes_read.py
from fastapi.testclient import TestClient
from notes_read import app
client = TestClient(app)
def test_missing_token_is_401():
response = client.get("/tickets/TCK-1042/notes")
assert response.status_code == 401
assert response.json()["detail"] == "missing_token"
def test_bad_token_is_401():
response = client.get(
"/tickets/TCK-1042/notes",
headers={"Authorization": "Bearer nope"},
)
assert response.status_code == 401
assert response.json()["detail"] == "bad_token"
def test_foreign_org_is_404():
response = client.get(
"/tickets/TCK-1042/notes",
headers={"Authorization": "Bearer other_token"},
)
assert response.status_code == 404
assert response.json()["detail"] == "ticket_not_found"
def test_owner_can_read():
response = client.get(
"/tickets/TCK-1042/notes",
headers={"Authorization": "Bearer agent_token"},
)
assert response.status_code == 200
assert response.json()["notes"] == "Customer heard hold music."
def test_write_route_absent_while_plan_disabled():
response = client.post(
"/tickets/TCK-1042/notes",
json={"body": "hi", "idempotency_key": "abc12345"},
)
assert response.status_code in {404, 405}
pytest -q test_notes_read.py
What failed for me along the way is worth naming, because the failures were architectural, not cosmetic. I once let the model emit Alembic and auto-apply on startup, which is a cute demo until two replicas race the same lock. I once shipped GET and POST in the same pull request because empty SQLite returned green, and the first real tenant collision arrived as a 500. I once trusted a generated 403 for unknown ids, which is just an existence oracle with extra steps.
Production caveats pile up as soon as you leave the toy dictionary, and they should slow you down. A real notes column needs a reversible migration, a lock story for hot tickets, and an audit row the model must not invent ad hoc. Free servers are fine for proving status codes against a disposable schema, and they are the wrong place for customer tokens. If your world needs a signed vendor review, do not point production identity at a rehearsal host just because inference is free.
Who should not follow this path, even though I like it for full-stack teams shipping real apps? Skip it if you want the plan file to replace code review, because a JSON schema will not catch a destructive default. Skip it if your so-called read-only GET still executes a stored procedure with side effects, since the slice would be lying. Skip it if you cannot freeze the intent, because a chatty agent that renegotiates the resource on every turn will rot your tests in public.
Limitations of the example stay honest, and I will not dress them up as a platform. The in-memory map is not Postgres, and the bearer table is not your identity provider. The plan JSON does not handle partial applies, and I have not claimed latency or token-cost numbers because those claims would be theater without a measured run. The method is the gate, not the brand of host you used to exercise it.
I still like a free model for the plan draft and a free server for the curl matrix, because cheap rehearsal is the only rehearsal people actually run. If you want that loop on MonkeyCode, start with the GET slice and four status codes, not generated CRUD. Then tell me which layer handoff is least stable in your stack when the model sounds sure. Is it auth, migration apply, idempotency, or the moment someone flips write_allowed, and what status code did you actually see?
Here is a short checklist I reuse before I let any model-backed change gain write authority.
- Freeze an intent with
write_allowedfalse. - Accept a plan file and reject any plan that enables POST.
- Ship GET until 401, 403, 404, and 200 stay stable.
- Rehearse on a host you can delete without a change board.
- Flip write only after idempotency tests pass on a retry.
That is the whole argument, and I do not think it is a close call. Models can type quickly, but your job is keeping them from writing until the read path already tells the truth. If the GET slice cannot fail closed, the POST handler is not a feature yet.
Top comments (0)