Hour 11, the compose file looked harmless. A generated api service, a generated db service, and a healthcheck that curled http://localhost:3000/health. This checkout binds HTTP to 8081 and talks to Postgres through a Unix socket. Nothing in the tree mentioned 3000. The model had remembered a tutorial, not this repository.
I missed it until the probe and the process disagreed. Unit tests never opened a port. They imported the app object and used TestClient. The suite stayed green. Then traffic went to a process still listening on 8081, while the probe succeeded against a leftover demo on 3000. A hotel keycard for room 3000 is not a bug in the lock. It is a bug in the assumption about where you live.
Hours 0–8 were spent treating the symptom. I reverted the compose patch, added PORT=8081 to .env.example, and asked for another generation. The second patch moved 3000 into a comment and left EXPOSE 3000 in the Dockerfile. Comments are not runtime. EXPOSE is a note for operators, not a bind(). The process still listened on 8081. The probe still hunted 3000.
What broke was not “the model is bad at Docker.” What broke was the missing contract between generated files and the runtime the repo already had. Agents fill blanks. Training data is full of Express-on-3000 and Postgres-on-5432. If your service is a socket and a non-default port, the blank looks like a question. The model answers with a memory.
The generated Dockerfile was the quiet accomplice. It compiled. It even built in CI, because nothing in the image build binds a port.
# generated, then reverted — do not copy as a target
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install -e .
EXPOSE 3000
CMD ["uvicorn", "svc.main:app", "--host", "0.0.0.0", "--port", "3000"]
The application code still read os.environ.get("PORT", "8081"). Two defaults, two clocks. The container started on 3000 only if the generated CMD won. Local uvicorn still started on 8081. Tests imported the module and never saw either process. That is how a port bug survives a green pipeline: the suite is testing a function, and production is testing a socket.
I stopped asking for a better patch and wrote a file the patch had to lose against. The contract is boring on purpose. It lists bind addresses, probe URLs, and the database transport operators actually run. It does not list hopes.
{
"http_bind_port": 8081,
"http_bind_host": "0.0.0.0",
"probe_url": "http://127.0.0.1:8081/health",
"forbidden_ports": [3000, 5432, 8000, 8080],
"postgres_transport": "unix_socket",
"postgres_socket": "/var/run/postgresql/.s.PGSQL.5432"
}
Hour 18 I wired a checker that walks generated compose files, Dockerfiles, and shell probes, then fails if a literal host, port, or scheme disagrees with the contract. Run it on the dirty tree, not on the prompt. The prompt is a story. The tree is the evidence.
#!/usr/bin/env python3
"""Fail the build when generated ops files invent a runtime the repo does not run."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CONTRACT = json.loads((ROOT / "ops" / "runtime-contract.json").read_text())
SCAN_GLOBS = (
"docker-compose*.yml",
"Dockerfile*",
"scripts/*",
"ops/*.sh",
)
PORT_RE = re.compile(r"(?:EXPOSE|PORT|--port|localhost:|127\.0\.0\.1:)\s*(\d{2,5})", re.I)
SOCKET_RE = re.compile(r"postgresql://[^\s\"']+")
def iter_ops_files() -> list[Path]:
files: list[Path] = []
for pattern in SCAN_GLOBS:
files.extend(ROOT.glob(pattern))
return [p for p in files if p.is_file()]
def main() -> int:
expected = str(CONTRACT["http_bind_port"])
forbidden = {str(p) for p in CONTRACT["forbidden_ports"]}
failures: list[str] = []
for path in iter_ops_files():
text = path.read_text(errors="ignore")
for match in PORT_RE.finditer(text):
port = match.group(1)
if port in forbidden or port != expected:
failures.append(f"{path.relative_to(ROOT)}: port {port} ({match.group(0)!r})")
if CONTRACT["postgres_transport"] == "unix_socket":
for match in SOCKET_RE.finditer(text):
failures.append(f"{path.relative_to(ROOT)}: tcp postgres url {match.group(0)!r}")
if failures:
print("runtime contract failed:")
print("\n".join(f"- {item}" for item in failures))
return 1
print(f"runtime contract ok across {len(iter_ops_files())} files")
return 0
if __name__ == "__main__":
sys.exit(main())
The first run failed on four literals: the compose healthcheck, EXPOSE 3000, a generated start.sh that exported PORT=3000, and a pytest that had been rewritten to hit http://127.0.0.1:3000. That last one was the expensive miss. The agent had “fixed” a skipped integration test by pointing it at whichever port the model liked. On a laptop with a leftover demo, the test would have passed. It would have measured the wrong process.
# tests/test_probe_contract.py
import json
from pathlib import Path
CONTRACT = json.loads(Path("ops/runtime-contract.json").read_text())
def test_probe_url_uses_bound_port():
assert CONTRACT["probe_url"].endswith(f":{CONTRACT['http_bind_port']}/health")
assert ":3000/" not in CONTRACT["probe_url"]
Hour 22 I needed a second generation that could not see my laptop. Listening ports leak into prompts when someone pastes ss -lntp, or when a helper snapshots the environment “for context.” I replayed the same instruction with MonkeyCode's free model access on the free server option, where port 3000 was closed. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The replay still proposed 3000 in one of two samples. Isolation does not delete training priors. It only stops the generator from confirming a port that happens to be live on the workstation. That is the reason to generate on a spare machine: your laptop is an accidental oracle. A closed port on the server is not a better model. It is a cleaner room.
I kept a small harness so the next 48 hours would not depend on memory. The script is labeled as a local helper, not as a benchmark. It writes two patches, runs the checker on each, and prints whether the failure is “prompt” or “workstation leak.”
#!/usr/bin/env bash
# scripts/replay_runtime_patch.sh — local helper, not a published benchmark
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PROMPT_FILE="$ROOT/ops/agent-prompt.txt"
OUT_A="$ROOT/tmp/gen-a"
OUT_B="$ROOT/tmp/gen-b"
mkdir -p "$OUT_A" "$OUT_B"
# Replace the next two lines with whatever command you use to apply a patch
# from a saved prompt. Keep the prompt file identical across machines.
apply_prompt() { local dest="$1"; cp -R "$ROOT/ops/fixtures/generated/." "$dest/"; }
apply_prompt "$OUT_A"
apply_prompt "$OUT_B"
python3 "$ROOT/scripts/check_runtime_contract.py" || true
echo "diffing generated ops files"
diff -ru "$OUT_A" "$OUT_B" || true
Two artifacts matter. The contract is the floor. The replay is the lighting. If both samples fail the contract, the prompt is under-specified. If only the laptop sample passes, the environment leaked. If both pass, you still review the diff like any other PR. Green checkers are not design review. They are a net under the circus tent.
What I would repeat is the order of operations. Commit the runtime contract before an agent is allowed to touch compose, Dockerfiles, or probe scripts. Run the checker in CI on the working tree, not on a chat transcript. Replay at least once on a machine that does not host hobbies on 3000, 5432, or 8080. Treat EXPOSE as a comment with extra confidence, which is to say: do not treat it as a bind. Name the port in a file the model can read. Adjectives are not configuration. “Correct,” “standard,” and “default” are how 3000 gets back in.
# .github/workflows/runtime-contract.yml
name: runtime-contract
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python3 scripts/check_runtime_contract.py
- run: python3 -m pytest tests/test_probe_contract.py
What I would not repeat is pasting docker ps into the prompt to “help.” That is how a leftover container becomes a specification. I would also not ask the model to invent a healthcheck for an app whose /health handler was never written. A probe that 200s on a process that is not yours is worse than a red build. Red is information. A polite lie is routing.
Limitations sit in the open. A literal matcher misses interpolation such as ${PORT}, computed URLs, and a probe that shells out to $(grep ...). It will not catch a health handler that returns 200 while the worker pool is dead. It will not catch a hostname that matches the contract by luck. Teams that generate infrastructure and apply it before the checker runs are not helped by this file; they need a gate, not a gist. People whose laptops really do bind 3000 should put that fact in the contract instead of arguing with the prior. This method is also the wrong tool for TLS settings, IAM, or database URLs that fail in ways a port integer cannot describe.
The useful residue after 48 hours is small. Keep a committed runtime contract. Keep a checker that reads files, not vibes. Generate once somewhere that does not already listen on the tutorial ports. The rest is still a diff, and the diff still needs a human.
Top comments (0)