The projector showed a 201. A student clapped once, then went quiet when the whiteboard still said 200. The JSON body also carried an owner_email field the room had never written down. Twelve laptops were two hours into a parking-hold lab, and the model had been more generous than the contract.
Cheap generation does not cheapen the interface. It makes the interface the only thing worth freezing before anyone opens a chat window. This outline is a two-hour workshop a facilitator can run with stock Python. Students leave with a scorer they can rerun, not a story about velocity.
The scene above is the teaching problem. A model that writes handlers from a paragraph will invent verbs, status codes, and PII-shaped fields because English is not a schema. The lab treats that invention as expected weather. The route is taped to the wall first. The scorer is written second. Only then does anyone ask a model for code.
What the room freezes
The whole product for the evening is one route. POST /holds accepts a lot identifier, a plate, and a duration in minutes. A 200 response echoes the lot, returns a hold_id, and includes an expires_at timestamp. Anything else is a story the scorer is allowed to fail.
Save this file as contract.json and keep it in the repo root. Students may not edit it after minute fifteen. The freeze is the pedagogy.
{
"method": "POST",
"path": "/holds",
"request_required": ["lot_id", "plate", "minutes"],
"request_types": {
"lot_id": "string",
"plate": "string",
"minutes": "integer"
},
"minutes_min": 15,
"minutes_max": 180,
"status": 200,
"response_required": ["hold_id", "lot_id", "expires_at"],
"forbidden_response_fields": ["price", "owner_email", "ssn", "token"]
}
A frozen route is a fence, not a novel. The analogy that lands in a noisy lab is a parking boom, not a city map. The boom either lifts or it does not. A generated README about "the future of curb inventory" does not lift it.
Minutes 0–20: write the scorer first
Facilitators who skip this block get a room full of green unit tests that never hit the extra fields. The scorer talks HTTP, not frameworks. It posts a fixture, checks the status, checks required keys, rejects forbidden keys, and parses expires_at as UTC.
# score_holds.py
from __future__ import annotations
import json
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
CONTRACT = json.loads(Path("contract.json").read_text())
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8080"
FIXTURE = {"lot_id": "LOT-A", "plate": "TEST-001", "minutes": 30}
def fail(msg: str) -> None:
print(f"FAIL: {msg}")
raise SystemExit(1)
def main() -> None:
req = urllib.request.Request(
BASE.rstrip("/") + CONTRACT["path"],
data=json.dumps(FIXTURE).encode(),
headers={"Content-Type": "application/json"},
method=CONTRACT["method"],
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
status = resp.status
body = json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
fail(f"status {exc.code}, expected {CONTRACT['status']}")
except Exception as exc: # lab scorer: surface transport errors plainly
fail(f"transport: {exc}")
if status != CONTRACT["status"]:
fail(f"status {status}, expected {CONTRACT['status']}")
if not isinstance(body, dict):
fail("response is not an object")
for key in CONTRACT["response_required"]:
if key not in body:
fail(f"missing {key}")
extra = [k for k in CONTRACT["forbidden_response_fields"] if k in body]
if extra:
fail(f"forbidden fields present: {extra}")
if body.get("lot_id") != FIXTURE["lot_id"]:
fail("lot_id was not echoed")
try:
stamp = datetime.fromisoformat(str(body["expires_at"]).replace("Z", "+00:00"))
except ValueError:
fail("expires_at is not ISO-8601")
if stamp.tzinfo is None:
fail("expires_at is naive")
if stamp <= datetime.now(timezone.utc):
fail("expires_at is not in the future")
print("PASS")
if __name__ == "__main__":
main()
Run it against nothing on purpose. The first failure is the lesson that the harness exists without an implementation.
python3 score_holds.py http://127.0.0.1:8080
Students who start by generating a server have already left the workshop. The scorer is the product. The handler is disposable clay.
Minutes 20–40: a stub that fails honestly
Hand out a server that listens and lies. It returns 201, a price, and an email, which is exactly the shape a chatty model likes to invent. The room should watch score_holds.py print FAIL three different ways before anyone prompts a model.
# bad_server.py — intentional contract drift for the first demo
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", "0"))
_ = self.rfile.read(length)
payload = {
"hold_id": "temp",
"lot_id": "WRONG",
"price": 4.5,
"owner_email": "desk@example.com",
}
raw = json.dumps(payload).encode()
self.send_response(201)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def log_message(self, fmt, *args):
return
if __name__ == "__main__":
ThreadingHTTPServer(("127.0.0.1", 8080), Handler).serve_forever()
Start it in one terminal and score it in another. The failures are the curriculum: wrong status, wrong lot, forbidden fields. A facilitator who narrates those three lines saves twenty minutes of later debate about whether the model was "basically right."
Minutes 40–70: generate against the freeze
The prompt is boring on purpose. Students paste contract.json and this sentence, nothing else: implement a Python 3 stdlib server that satisfies this contract and listens on 8080. No product pitch. No parking manifesto. No authentication subplot.
Facilitators who do not want to provision GPUs can run that generation step against MonkeyCode's free model access and point the resulting process at its free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Those two availability claims are the only product facts this lab needs. Model names, token ceilings, and hardware details are out of scope here because they change and because the scorer does not care which vendor emitted the bytes.
The worked implementation students can rerun looks like the following. Label it as a reference solution, not as a transcript of a particular model run. A real lab will produce messier files. The scorer is what keeps the mess from shipping.
# holds_server.py — reference solution for the freeze, not a model transcript
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from datetime import datetime, timedelta, timezone
from uuid import uuid4
import json
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/holds":
self.send_error(404)
return
length = int(self.headers.get("Content-Length", "0"))
try:
body = json.loads(self.rfile.read(length).decode() or "{}")
except json.JSONDecodeError:
self.send_error(400)
return
lot_id = body.get("lot_id")
plate = body.get("plate")
minutes = body.get("minutes")
if not isinstance(lot_id, str) or not isinstance(plate, str):
self.send_error(400)
return
if not isinstance(minutes, int) or not (15 <= minutes <= 180):
self.send_error(400)
return
payload = {
"hold_id": str(uuid4()),
"lot_id": lot_id,
"expires_at": (
datetime.now(timezone.utc) + timedelta(minutes=minutes)
).isoformat().replace("+00:00", "Z"),
}
raw = json.dumps(payload).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def log_message(self, fmt, *args):
return
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
python3 holds_server.py
python3 score_holds.py http://127.0.0.1:8080
A PASS here is not a product launch. It is proof that the frozen route and the process agree for one fixture. That is the entire bar for hour one.
Minutes 70–100: mutate the fence
Change one number in contract.json. Raise minutes_min from 15 to 45, or add spot_id to response_required. Do not touch the server. Run the scorer again and wait for FAIL.
This is the week’s actual argument about cheap code. When generation is fast, drift is also fast. A route that is not re-scored after a one-line contract edit is already folklore. Students should feel the FAIL in the same terminal that printed PASS twenty minutes earlier. The contrast teaches more than a slide about technical debt.
A second mutation is enough. Point GET at /holds with curl and confirm the scorer still speaks only POST. Then post a body with minutes as a string. The reference server rejects it. Many generated servers will coerce the string and look helpful. Helpful coercion is still a broken freeze.
curl -sS -D - -o /tmp/hold.json \
-H 'Content-Type: application/json' \
-d '{"lot_id":"LOT-A","plate":"TEST-001","minutes":"30"}' \
http://127.0.0.1:8080/holds
Facilitators keep a parking lot of extra mutations and do not run them unless the room is early. Extra fields in the request, a naive expires_at, a 201 that otherwise looks perfect. Each one is a five-minute drill, not a new lecture.
Minutes 100–120: debrief without a victory lap
Name the three failures the stub produced. Name the mutation that broke the passing server. Collect one generated file that added token or price and read that object aloud. The room now has language for a review comment that is not "the AI did a pretty good job."
The artifact to keep is the pair of files: contract.json and score_holds.py. The handler can be deleted. Next week’s lab can freeze a different route and reuse the same scoring shape. That reuse is the point. Workshops that ship a framework tour do not transfer. A scorer transfers.
Limitations
The harness is syntactic. It does not prove that hold_id values are unique, that holds expire, or that two overlapping plates conflict. It does not speak TLS, auth, or idempotency keys. A 200 with the right keys can still be a lie about the parking lot.
Free model access and a free server are lab plumbing. They are not an SLA. Shared or short-lived hosts drop connections. Generated code may bind 127.0.0.1 when the scorer runs elsewhere, or it may listen on a different path because the prompt drifted. The freeze does not prevent that. It only makes the miss loud.
This outline also refuses several audiences on purpose. It is the wrong tool for a production permitting system, a graded exam that still needs human design review, or a team that will not freeze a contract before prompting. Facilitators who let students describe "a parking app" in a paragraph will reproduce the opening scene. People hunting for model leaderboards will be bored. The lab does not rank vendors.
Clock time assumes a room that already has Python 3. If half the laptops are installing runtimes, cut the second mutation and keep the honest stub. The stub is the cheaper half of the teaching.
Print the contract. Score a liar. Generate against the freeze. Score again. Mutate one field. Score a third time. That sequence is the workshop. The model is a guest in it, not the host.
Top comments (0)