You open the laptop because a health check failed, and the room is quiet enough that the fan sounds like blame. The preview database that fed tomorrow's demo now answers every query with a relation that no longer exists. You did not type the destructive statement yourself, which is exactly why the timeline matters more than the apology. A postmortem maps how a small permission became a large outage, and this reconstructed lab scene is one you can replay.
The scene is deliberately reconstructed from a pattern that shows up in side projects, not a customer outage with measured revenue loss. You had a coding agent on a shell, a migration folder, and a connection string whose names had been copied from production. The agent was asked to make the demo schema match the branch, which a human hears as careful and a tool hears as permission. By the time you noticed, the preview schema was gone and the shell history was the only artifact you had not been reading.
What the clock actually shows
At 01:40 you started a session to reconcile a feature branch with the preview database before a morning walkthrough. At 01:52 the agent proposed a migration that dropped a table from an older experiment and recreated it under a new name. At 02:04 you approved a diff that showed the new table and hid the drop inside a helper the diff view had collapsed. At 02:17 the health check failed, and the statement had already committed because the connection was not wrapped in a transaction you controlled.
Detection was late because the health check looked for a running process, not for the table the demo actually selected. Recovery was a restore from the last seed, which worked only because preview held no data a person would mourn. What changes the next run is an unmarked DSN refusal, a destructive-class refusal, and a rule that a collapsed diff is not review. A recap that ends with a plea to be more careful is how the same incident gets a sequel.
What made the drop possible
You can blame the model, and the blame will feel tidy, but it will not survive contact with the timeline. A paid key with the same shell, the same DSN, and the same collapsed diff would have committed the same drop. The missing gate is the factor you can change without pretending the next model release is itself a control. Price, brand, and a fluent apology all sit outside the commit path, which is why they do not belong in the fix.
Three factors stacked, the way a chair fails when one leg is short and you keep leaning. The first was scope, because the prompt never named the database and the tool inherited whatever DSN sat in the environment. The second was review shape, because a collapsed helper is not a review and a green suite does not prove the drop was absent. The third was environment honesty, because a production-shaped name feels interchangeable with preview at two in the morning.
The gate that should have existed
The durable fix is not a smarter prompt and not a promise that the model will be careful next time. You put a gate in front of the shell, and it refuses to run until the target and the statement class are both explicit. Think of that gate as a lock on the stairwell rather than another speech about not falling. The agent can still draft SQL, but apply rights live in a script you can read, version, and test without a chat transcript.
Save the gate as preview_gate.py and treat it as a proposed control, not as a measured production control from a named company. It reads the SQL from a file, refuses obvious destructive forms, and requires the DSN to contain a marker such as preview or sandbox. It also refuses to continue when BEGIN is missing, because an autocommit connection turns a mistake into a commit. You can extend the patterns, but you should not delete the refusal path to make a demo look smoother.
#!/usr/bin/env python3
"""Proposed preview apply gate. Unexecuted example, not a measured control."""
import os
import re
import sys
from datetime import datetime, timezone
DESTRUCTIVE = re.compile(
r"\b(drop\s+table|drop\s+schema|truncate|delete\s+from\s+\w+\s*;)\b",
re.I,
)
MARKERS = ("preview", "sandbox", "local")
def refuse(code: int, reason: str) -> int:
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
line = f"{stamp} refuse code={code} reason={reason}\n"
with open("gate.log", "a", encoding="utf-8") as handle:
handle.write(line)
print(f"refuse: {reason}", file=sys.stderr)
return code
def main() -> int:
sql_path = sys.argv[1] if len(sys.argv) > 1 else "migration.sql"
dsn = os.environ.get("DATABASE_URL", "")
with open(sql_path, encoding="utf-8") as handle:
sql = handle.read()
if not any(marker in dsn.lower() for marker in MARKERS):
return refuse(2, "DSN is not marked preview, sandbox, or local")
if DESTRUCTIVE.search(sql):
return refuse(3, "destructive statement class")
if not re.search(r"\bBEGIN\b", sql, re.I):
return refuse(4, "missing explicit BEGIN")
print("allow: statement class passed the gate")
print(f"next: psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f {sql_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
You exercise the gate with three local commands before you ever point it at a real preview database. A clean file that starts with BEGIN should exit zero, and a file that contains DROP TABLE should exit three. A DSN without a preview marker should exit two even when the SQL is harmless, because the target check is independent. That independence is the point, since a polite query against the wrong database is still the wrong database.
export DATABASE_URL="postgres://app:app@127.0.0.1:5432/demo_preview"
printf 'BEGIN;\nSELECT 1;\n' > ok.sql
python3 preview_gate.py ok.sql
printf 'BEGIN;\nDROP TABLE users;\n' > bad.sql
python3 preview_gate.py bad.sql
DATABASE_URL="postgres://app:app@127.0.0.1:5432/app_prod" \
python3 preview_gate.py ok.sql
tail -n 5 gate.log
Where a hosted assistant can sit
This is the point where a hosted assistant can join the workflow without owning the apply decision. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The brief for this article says MonkeyCode offers free model access and a free server option, and those are the only product facts used. You can draft migration commentary with that free model access, then run the gate beside a throwaway database on the free server option.
Ask the model to explain the diff and to name every statement class, then keep that note beside the gate output. If the model and the gate disagree, the gate wins, because a fluent explanation is not a transaction boundary. Do not ask the model to invent a quota, a benchmark, or a hardware specification, and do not paste a production DSN into chat. The free server is useful as a sandbox with a marked name, not as a shortcut around the permission mistake that emptied preview.
Who should skip the approach
This approach is a poor fit if you need a certified change window or a promise that free capacity remains next week. It is also a poor fit when the data cannot be regenerated, because a local regex misses destructive SQL hidden in dynamic strings. Teams that already run expand-contract migrations with locked production credentials do not need this script as a replacement. They can still borrow the habit of refusing unmarked DSNs, which is the part that survives after the model leaves the story.
The regex is intentionally narrow, and a determined string can evade it by splitting keywords or by calling a function that drops a table. The script does not execute SQL, which is both a limitation and a feature, because it cannot save you if you bypass it. It does not know your business rules, so a legitimate rebuild of an empty preview table stays refused until you add an allow path. Add that path as a separate command with a logged reason, not as a comment you leave inside the prompt.
After you rebuild preview from seed data, write the timeline next to the gate so the next session inherits the constraint. You will still make schema mistakes, but they should fail closed in a marked sandbox rather than on a forgotten connection. If you rehearse this with free model access and a free server, read the current terms and use a database you can delete. The article remains useful if that product disappears, because the gate does not depend on it.
Top comments (0)