You can prototype on a free model and a free server. You cannot promote that host. Treat the lab as disposable, pin a promotion contract, and fail closed when the evidence files are missing.
That is the rule. Everything below is a copy-paste checklist for the cutover, not another merge ritual.
The leak you will actually ship
AI-assisted services rarely blow up because a generated loop is ugly. They blow up because the lab leaked.
You scaffold a handler. You run it on the same box that hosts the assistant. You paste the lab URL into a client. You leave a debug token in .env. A teammate then deploys "the same thing."
Unit tests will not catch that. A PR checklist about tools or retries will not catch it either. Promotion is a different gate. If the process that built the service still owns the runtime, you do not have production. You have a shared notebook with a port.
What counts as a lab
Call it a lab when all of these are true:
- The coding assistant, the app process, and scratch data can share a host.
- Model access is convenience-first, not contract-first.
- Secrets are whatever unblocked the demo.
- Nobody can name the production identity of the service.
Free model access and a free server option are useful in that phase. They become a problem only when you skip the cutover and ship the box you happened to be using.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
If you want a scratch environment with free model access and a free server option, MonkeyCode is an open-source project some teams park that work on. The checklist does not depend on it. Strip the product name out and the gates still stand.
Copy this promotion contract
Keep one file in the repo: PROMOTION.yaml. Deploy jobs should refuse to start unless every gate is pass and every evidence path exists on disk. Do not type pass by hand. Generate the files, then let a checker read both.
# PROMOTION.yaml — proposal for a local clone, not an executed audit
version: 1
service: billing-adapter
lab:
allowed_hosts:
- "127.0.0.1"
- "lab.internal.example"
must_not_appear_in:
- deploy/
- infra/
- clients/
gates:
- id: HOST_SPLIT
question: "Is production a different host than the lab?"
evidence: evidence/host-split.txt
fail_closed: "Refuse deploy if a lab hostname appears under deploy/, infra/, or clients/."
- id: SECRET_MATERIAL
question: "Were lab tokens, cookies, and debug keys rotated out?"
evidence: evidence/secret-scan.json
fail_closed: "Refuse deploy if any lab secret pattern still matches."
- id: MODEL_PIN
question: "Is every model call pinned to a named contract, not the lab default?"
evidence: evidence/model-pin.md
fail_closed: "Refuse deploy if the client still reads LAB_MODEL or an unset MODEL_ID."
- id: DATA_CLASS
question: "Is production data blocked from the lab host?"
evidence: evidence/data-class.md
fail_closed: "Refuse deploy if the lab can still reach prod datastores."
- id: OBSERVABILITY
question: "Can you name the prod service in logs, traces, and alerts without the lab project name?"
evidence: evidence/obs-names.txt
fail_closed: "Refuse deploy if service.name still equals the assistant project."
- id: BLAST_RADIUS
question: "Is there a one-command rollback that does not SSH into the lab?"
evidence: evidence/rollback.md
fail_closed: "Refuse deploy if rollback docs mention the lab host."
Six gates. Six files. If a file is missing, the answer is no.
Fail-closed checker you can run locally
The script below is a proposal. Run it in a clone you control. It does not open a network connection. It fails if evidence is missing, if a gate is not pass, or if a forbidden lab host string appears under the listed trees.
#!/usr/bin/env python3
"""promotion_gate.py — fail closed when lab evidence is missing."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print("install pyyaml in your local venv before running", file=sys.stderr)
sys.exit(2)
REQUIRED_STATUS = {"pass"}
def load_contract(path: Path) -> dict:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(data, dict) or "gates" not in data:
raise ValueError("PROMOTION.yaml must contain a gates list")
return data
def scan_forbidden_hosts(root: Path, hosts: list[str], trees: list[str]) -> list[str]:
hits: list[str] = []
for tree in trees:
base = root / tree
if not base.exists():
continue
for path in base.rglob("*"):
if not path.is_file():
continue
text = path.read_text(encoding="utf-8", errors="ignore")
for host in hosts:
if host and host in text:
hits.append(f"{path}: contains {host}")
return hits
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--root", type=Path, default=Path("."))
parser.add_argument("--config", type=Path, default=Path("PROMOTION.yaml"))
args = parser.parse_args()
contract = load_contract(args.config)
failures: list[str] = []
for gate in contract["gates"]:
evidence = args.root / gate["evidence"]
if not evidence.is_file():
failures.append(f"{gate['id']}: missing {evidence}")
continue
marker = evidence.read_text(encoding="utf-8", errors="ignore")
if "STATUS: pass" not in marker:
failures.append(f"{gate['id']}: {evidence} has no STATUS: pass line")
lab = contract.get("lab", {})
hits = scan_forbidden_hosts(
args.root,
lab.get("allowed_hosts", []),
lab.get("must_not_appear_in", []),
)
failures.extend(hits)
if failures:
print("PROMOTION FAILED")
for item in failures:
print(f"- {item}")
return 1
print(json.dumps({"result": "ok", "gates": [g["id"] for g in contract["gates"]]}))
return 0
if __name__ == "__main__":
sys.exit(main())
Wire it so deploy never starts without the checker:
python3 -m pip install pyyaml
python3 promotion_gate.py --root . --config PROMOTION.yaml
.PHONY: promotion-gate
promotion-gate:
python3 promotion_gate.py --root . --config PROMOTION.yaml
If your pipeline cannot see evidence/, that is a fail, not a skip.
What each evidence file has to prove
Write short files. A novel in evidence/ is how teams hide a missing gate.
HOST_SPLIT
Prove the production runtime is a different machine, cluster, or account than the lab. A useful evidence/host-split.txt looks like this:
STATUS: pass
lab_host: 127.0.0.1
prod_runtime: ecs://billing-adapter-prod
checked_trees: deploy/, infra/, clients/
command: rg -n "127.0.0.1|lab.internal.example" deploy infra clients
If rg prints a line, you do not have a split. You have a rename.
SECRET_MATERIAL
Rotate anything the lab touched. Demo cookies, assistant tokens, copied Authorization headers, default DB passwords. Then record the scan, not the secret.
rg -n "LAB_|DEBUG_TOKEN|sk-live|BEGIN OPENSSH" --glob '!.git' . \
&& echo "still dirty" && exit 1
printf 'STATUS: pass\nscanner: rg\npatterns: LAB_, DEBUG_TOKEN\n' > evidence/secret-scan.json
Adjust the patterns for your repo. Do not paste live credentials into the evidence file. The file is a receipt, not a backup.
MODEL_PIN
Lab defaults are not a contract. Pin the caller so production cannot silently follow whatever the assistant used that afternoon.
STATUS: pass
caller: src/llm_client.py
required_env:
- MODEL_ID
- MODEL_ENDPOINT
- MAX_OUTPUT_TOKENS
forbidden_env:
- LAB_MODEL
unset_means: fail closed, do not fall back
If the client still does os.getenv("MODEL_ID", "whatever-the-lab-used"), the gate is red. A default is a second lab.
DATA_CLASS
Keep production records off the lab host. That includes logs that embed payloads, copied fixtures, and "just one customer row."
STATUS: pass
prod_datastores:
- postgres://prod-billing
lab_may_read:
- fixtures/synthetic/
lab_may_not_read:
- customers
- invoices
egress_check: no prod DSN in lab compose files
If your lab compose file still contains the prod DSN "for realism," throw the compose file away. Realism is not a reason to mix classes.
OBSERVABILITY
Rename the service before the first prod request. Lab project names in service.name, dashboard folders, or alert titles will page the wrong people.
rg -n "service.name|OTEL_SERVICE_NAME|job:" deploy infra \
| tee evidence/obs-names.txt
# then append:
# STATUS: pass
# prod_name: billing-adapter
# lab_name_absent: yes
BLAST_RADIUS
Rollback must not require SSH into the machine where you vibe-coded the demo. If the runbook says "just restart the lab process," you have no blast-radius control.
STATUS: pass
rollback: kubectl rollout undo deploy/billing-adapter
does_not_use: ssh lab.internal.example
owner_alert: #billing-oncall
Decision table: promote, rebuild, or throw away
| Signal | Promote | Rebuild in prod-shaped infra | Throw the lab away |
|---|---|---|---|
| Lab hostname in clients | No | Yes, after stripping | If clients cannot be found |
| Model id unset at runtime | No | Yes, after pinning | If nobody can name the contract |
| Prod data reached the lab | No | Only after rotation and review | Default when customer data moved |
| Rollback needs lab SSH | No | Yes | If no other runtime exists |
| Evidence file missing | No | No | Yes, until the file exists |
Read the table left to right. Missing evidence is not "unknown." Missing evidence is "do not promote."
A 20-minute dry run
Label this unexecuted until you run it on your clone.
- Create
PROMOTION.yamland emptyevidence/files withoutSTATUS: pass. - Run
python3 promotion_gate.py. Confirm it exits1. - Put
http://127.0.0.1:8080intoclients/demo.env. Run it again. Confirm the host scan fails. - Remove the lab URL. Fill each evidence file with a
STATUS: passline and the commands you actually ran. - Run the checker again. Only then allow the deploy job to see the artifact.
If step 2 passes, the checker is wrong. A first run with empty evidence must fail.
Limitations
This checklist does not prove load, correctness, or latency. It does not replace threat modeling, IAM review, or a real staging environment. String search will miss encoded hostnames, image layers, and secrets injected at runtime. STATUS: pass is only as honest as the person who wrote it.
Free servers and free model access also have limits you cannot inventory from a YAML file: capacity, tenancy, retention, and how long the option remains available. Do not treat a lab uptime number as a production SLO. Do not treat a convenience default as a vendor contract.
Who should not use this
Skip this approach if you already promote through a locked-down path that forbids third-party assistants on any host that can see production data. Skip it if you need a compliance certificate; a repo checklist is not one. Skip it if the service never leaves the laptop. And skip it if you were about to point production DNS at the same process that runs the assistant.
In that last case the fix is not a better YAML file. The fix is a different host.
If you need a disposable lab before you run this checker, MonkeyCode's free model access and free server option are one way to keep that scratch work off the production cluster. Use it as a lab. Then make the checker fail until the lab disappears from deploy/.
Top comments (0)