Grade the Guesses: A Bootcamp Lab With an Assumption Gate A developer created a bootcamp lab that grades students on documenting assumptions before implementing code, using a Node checker and a frozen requirements file to enforce that only confirmed assumptions are used in code. The lab addresses the problem of AI coding agents generating plausible but unverified features by requiring students to maintain an ASSUMPTIONS.md table with statuses like CONFIRMED, GUESSED, and REJECTED. Cheap AI code is not the hard part. Unstated assumptions are. A student types "add a waitlist endpoint." The agent returns JWT, Redis, Postgres, and a welcome email. Looks senior. It is mostly fiction. This lab grades the guess log first. The diff comes second. If that sounds harsh, good. Bootcamps keep shipping features that never had a chance to be true. I wrote it because pull requests that solve an unassigned product are a grading nightmare. The brief said POST /waitlist with an email. That is it. No auth story. No provider. No queue. The model filled every silence with a vendor. Students accepted the story because the code compiled. Sound familiar? Agents are autocomplete with confidence. Confidence is not evidence. This lab teaches one mechanical habit: no implementation until every material assumption is tagged. We will use a tiny Node checker, a frozen requirements file, and a four-checkpoint rubric. You can run the whole thing on a laptop. The lab still works if you delete the optional infra paragraph below. Students get one page. Instructors do not "clarify" in Slack. Ambiguity is the point. Product brief frozen : POST /waitlist { "email": string } 201 with { "ok": true } That is the entire product. Everything else is a guess. server.js with a health check only, plus REQUIREMENTS.md the brief above and an empty ASSUMPTIONS.md . node -v npm init -y node scripts/check-assumptions.mjs If the checker fails on an empty repo, that is correct. An empty assumption log is a failing lab, not a blank canvas. Students must keep ASSUMPTIONS.md in this shape: Assumptions | id | claim | status | evidence | |----|-------|--------|----------| | A1 | POST /waitlist accepts JSON { email } | CONFIRMED | REQUIREMENTS.md | | A2 | In-memory storage is allowed | CONFIRMED | REQUIREMENTS.md | | A3 | Duplicate emails should return 409 | GUESSED | not in brief | | A4 | We should send a confirmation email | REJECTED | out of scope | Status is a closed enum: CONFIRMED — quoted from REQUIREMENTS.md or a written instructor answer REJECTED — considered and explicitly out of scope GUESSED — the agent or the student filled a silence Budget rule: submitted code may depend on CONFIRMED rows only. GUESSED rows live in the table. They must not appear as branches, dependencies, or env vars. REJECTED rows are not a backlog. They are a fence. Why a table instead of a vibe-y architecture note? Because I can grade a table. I cannot grade "we thought about architecture." | If you see this in the diff | Required row | Else | |---|---|---| jsonwebtoken , sessions, API keys | auth assumption, almost always REJECTED | fail checkpoint 2 | | Redis, queues, workers | durability / rate-limit assumption | fail unless CONFIRMED | | nodemailer, SendGrid, SMTP | email-send assumption | fail | | Postgres, Prisma, SQLite file | persistence assumption | fail unless the brief changed | 409 on duplicates | uniqueness assumption | allowed only if CONFIRMED , or parked as GUESSED with no code | Save as scripts/check-assumptions.mjs . It is deliberately picky. Treat it as a lab tool, not a production linter. python /usr/bin/env node import fs from "node:fs"; import path from "node:path"; const ROOT = process.cwd ; const MAX GUESSED IN CODE = 0; const SMELLS = /jsonwebtoken/i, /express-session/i, /redis/i, /nodemailer/i, /sendgrid/i, /mongoose|prisma|sequelize/i, /postgres|mongodb/i, /process\.env\. A-Z0-9 +/, ; function read file { return fs.readFileSync path.join ROOT, file , "utf8" ; } function parseAssumptions md { const rows = ; for const line of md.split "\n" { if /^\|\s A\d+/i.test line continue; const cols = line.split "|" .map c = c.trim .filter Boolean ; if cols.length < 4 continue; rows.push { id: cols 0 , claim: cols 1 , status: cols 2 .toUpperCase , evidence: cols 3 , } ; } return rows; } function walk dir, acc = { for const ent of fs.readdirSync dir, { withFileTypes: true } { if "node modules", ".git", "scripts" .includes ent.name continue; const p = path.join dir, ent.name ; if ent.isDirectory walk p, acc ; else if /\. js|mjs|cjs|ts $/.test ent.name acc.push p ; } return acc; } const rows = parseAssumptions read "ASSUMPTIONS.md" ; if rows.length < 3 { console.error "Need at least 3 assumption rows. Silence is not a design." ; process.exit 1 ; } const allowed = new Set "CONFIRMED", "REJECTED", "GUESSED" ; for const r of rows { if allowed.has r.status { console.error ${r.id} has illegal status ${r.status} ; process.exit 1 ; } if r.status === "CONFIRMED" && /REQUIREMENTS\.md|instructor/i.test r.evidence { console.error ${r.id} is CONFIRMED without evidence ; process.exit 1 ; } } const guessed = rows.filter r = r.status === "GUESSED" ; const sourceFiles = walk ROOT ; const smellHits = ; for const file of sourceFiles { const txt = fs.readFileSync file, "utf8" ; for const re of SMELLS { if re.test txt smellHits.push { file, re: String re } ; } } if smellHits.length { console.error "Infrastructure smells need a CONFIRMED or REJECTED row, not hope." ; for const h of smellHits console.error ${h.file} ~ ${h.re} ; process.exit 1 ; } if guessed.length MAX GUESSED IN CODE { // GUESSED rows are allowed in the table. They are not allowed to drive code. // The smell pack above is the cheap proxy for "drove code." } console.log ok: ${rows.length} rows, ${guessed.length} parked guesses, ${sourceFiles.length} source files ; Run it like a unit test: node scripts/check-assumptions.mjs echo $? 0 is the only passing grade for checkpoint 2 Is the smell list complete? No. It is a teaching fence. Students who rename nodemailer to mailer.mjs and call SMTP anyway still fail the human review. The script exists so the obvious fiction dies in CI. Paste REQUIREMENTS.md into the repo. Do not edit it. If the agent rewrites the brief, that is an automatic zero for this checkpoint. Why so strict? Because "helpful" rewrites are how a waitlist becomes a growth stack. Fill ASSUMPTIONS.md with at least three rows before server.js gains a route. Commit that file alone. I want a git log that proves the log came first. Ask the agent a rude prompt and stop there: Read REQUIREMENTS.md. List every assumption you would need to implement POST /waitlist. Tag each CONFIRMED, GUESSED, or REJECTED. Do not write code. Do not add dependencies. If it still opens a Prisma schema, you have a process bug, not a model bug. Add POST /waitlist . Keep storage in a module-level array. Return 201. No extra packages unless a CONFIRMED row names them. Then run the checker. A legal happy path looks boring. Boring is the point. // server.js — labeled example for the lab, not a framework recommendation import http from "node:http"; const waitlist = ; const server = http.createServer async req, res = { if req.method === "POST" && req.url === "/waitlist" { const chunks = ; for await const c of req chunks.push c ; let body; try { body = JSON.parse Buffer.concat chunks .toString "utf8" ; } catch { res.writeHead 400, { "content-type": "application/json" } ; res.end JSON.stringify { error: "invalid json" } ; return; } if typeof body?.email == "string" || body.email.includes "@" { res.writeHead 400, { "content-type": "application/json" } ; res.end JSON.stringify { error: "invalid email" } ; return; } waitlist.push { email: body.email, at: Date.now } ; res.writeHead 201, { "content-type": "application/json" } ; res.end JSON.stringify { ok: true } ; return; } res.writeHead 404 ; res.end ; } ; server.listen 3000 ; Did that @ check invent a validation rule? Yes. Park it as GUESSED or strip it. See how fast the habit shows up? Smoke test without extra libraries: node server.js & curl -sS -D - -o /tmp/wl.json \ -H 'content-type: application/json' \ -d '{"email":"dev@example.com"}' \ http://127.0.0.1:3000/waitlist cat /tmp/wl.json You want 201 and {"ok":true} . Anything else is either a broken student or a broken brief. Do not "fix" it by adding Redis. Instructor adds one sentence: duplicate emails return 409 . Students must: GUESSED to CONFIRMED with evidence pointing at the new sentenceIf they change code without touching the table, they fail even if the HTTP behavior is right. We are grading the coupling, not the status code. CONFIRMED row that names the package. REJECTED rows into tests that must | Area | Points | Pass bar | |---|---|---| Checkpoint 0: unmodified REQUIREMENTS.md | 10 | file hash matches the handout | | Checkpoint 1: assumption commit before route commit | 20 | git log order is checkable | | Table quality | 20 | ≥3 rows, legal statuses, CONFIRMED has evidence | | Checkpoint 2: checker exit 0 + 201 path | 25 | curl shown in the README | | Checkpoint 3: brief mutation reflected in table and code | 15 | 409 only after the table update | | Human review: no silent vendors | 10 | reviewer can name every extra import | Automatic zeros: rewriting the brief, committing secrets, adding a paid API call to "finish" the lab, or deleting the checker. I do not grade prose style. I do not grade how "production ready" the waitlist looks. Production-ready was how we got a fake email pipeline. This is a teaching protocol. It is not an architecture review board. https.request . That is why 10 points stay human. POST /waitlist still needs abuse thinking if you ever put it on the internet. This lab should stay on localhost.If your class is "build anything," this lab will feel like a muzzle. Use it in week 2, not week 12. The agent will still guess. That is its job. Your job is to make the guess visible, cheap, and fireable. Run the checker. Fail on purpose once. Then ask the model to list assumptions and stop. That prompt is the whole course if you are short on time. Steal the checker either way. If you need a zero-invoice box for the agent half of the exercise, MonkeyCode's free model access and free server option are how this lab keeps the infra row of the syllabus empty.