{"slug": "grade-the-guesses-a-bootcamp-lab-with-an-assumption-gate", "title": "Grade the Guesses: A Bootcamp Lab With an Assumption Gate", "summary": "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.", "body_md": "Cheap AI code is not the hard part. Unstated assumptions are.\n\nA 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.\n\nIf that sounds harsh, good. Bootcamps keep shipping features that never had a chance to be true.\n\nI wrote it because pull requests that solve an unassigned product are a grading nightmare.\n\nThe brief said `POST /waitlist`\n\nwith 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.\n\nSound familiar?\n\nAgents are autocomplete with confidence. Confidence is not evidence. This lab teaches one mechanical habit: **no implementation until every material assumption is tagged.**\n\nWe 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.\n\nStudents get one page. Instructors do not \"clarify\" in Slack. Ambiguity is the point.\n\n**Product brief (frozen):**\n\n`POST /waitlist`\n\n`{ \"email\": string }`\n\n`201`\n\nwith `{ \"ok\": true }`\n\nThat is the entire product. Everything else is a guess.\n\n`server.js`\n\nwith a health check only, plus `REQUIREMENTS.md`\n\n(the brief above) and an empty `ASSUMPTIONS.md`\n\n.\n\n```\nnode -v\nnpm init -y\nnode scripts/check-assumptions.mjs\n```\n\nIf the checker fails on an empty repo, that is correct. An empty assumption log is a failing lab, not a blank canvas.\n\nStudents must keep `ASSUMPTIONS.md`\n\nin this shape:\n\n```\n# Assumptions\n\n| id | claim | status | evidence |\n|----|-------|--------|----------|\n| A1 | POST /waitlist accepts JSON `{ email }` | CONFIRMED | REQUIREMENTS.md |\n| A2 | In-memory storage is allowed | CONFIRMED | REQUIREMENTS.md |\n| A3 | Duplicate emails should return 409 | GUESSED | not in brief |\n| A4 | We should send a confirmation email | REJECTED | out of scope |\n```\n\nStatus is a closed enum:\n\n`CONFIRMED`\n\n— quoted from `REQUIREMENTS.md`\n\nor a written instructor answer`REJECTED`\n\n— considered and explicitly out of scope`GUESSED`\n\n— the agent (or the student) filled a silence**Budget rule:** submitted code may depend on `CONFIRMED`\n\nrows only. `GUESSED`\n\nrows live in the table. They must not appear as branches, dependencies, or env vars. `REJECTED`\n\nrows are not a backlog. They are a fence.\n\nWhy a table instead of a vibe-y architecture note? Because I can grade a table. I cannot grade \"we thought about architecture.\"\n\n| If you see this in the diff | Required row | Else |\n|---|---|---|\n`jsonwebtoken` , sessions, API keys |\nauth assumption, almost always `REJECTED`\n|\nfail checkpoint 2 |\n| Redis, queues, workers | durability / rate-limit assumption | fail unless `CONFIRMED`\n|\n| nodemailer, SendGrid, SMTP | email-send assumption | fail |\n| Postgres, Prisma, SQLite file | persistence assumption | fail unless the brief changed |\n`409` on duplicates |\nuniqueness assumption | allowed only if `CONFIRMED` , or parked as `GUESSED` with no code |\n\nSave as `scripts/check-assumptions.mjs`\n\n. It is deliberately picky. Treat it as a lab tool, not a production linter.\n\n``` python\n#!/usr/bin/env node\nimport fs from \"node:fs\";\nimport path from \"node:path\";\n\nconst ROOT = process.cwd();\nconst MAX_GUESSED_IN_CODE = 0;\nconst SMELLS = [\n  /jsonwebtoken/i,\n  /express-session/i,\n  /redis/i,\n  /nodemailer/i,\n  /sendgrid/i,\n  /mongoose|prisma|sequelize/i,\n  /postgres|mongodb/i,\n  /process\\.env\\.[A-Z0-9_]+/,\n];\n\nfunction read(file) {\n  return fs.readFileSync(path.join(ROOT, file), \"utf8\");\n}\n\nfunction parseAssumptions(md) {\n  const rows = [];\n  for (const line of md.split(\"\\n\")) {\n    if (!/^\\|\\s*A\\d+/i.test(line)) continue;\n    const cols = line.split(\"|\").map((c) => c.trim()).filter(Boolean);\n    if (cols.length < 4) continue;\n    rows.push({\n      id: cols[0],\n      claim: cols[1],\n      status: cols[2].toUpperCase(),\n      evidence: cols[3],\n    });\n  }\n  return rows;\n}\n\nfunction walk(dir, acc = []) {\n  for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {\n    if ([\"node_modules\", \".git\", \"scripts\"].includes(ent.name)) continue;\n    const p = path.join(dir, ent.name);\n    if (ent.isDirectory()) walk(p, acc);\n    else if (/\\.(js|mjs|cjs|ts)$/.test(ent.name)) acc.push(p);\n  }\n  return acc;\n}\n\nconst rows = parseAssumptions(read(\"ASSUMPTIONS.md\"));\nif (rows.length < 3) {\n  console.error(\"Need at least 3 assumption rows. Silence is not a design.\");\n  process.exit(1);\n}\n\nconst allowed = new Set([\"CONFIRMED\", \"REJECTED\", \"GUESSED\"]);\nfor (const r of rows) {\n  if (!allowed.has(r.status)) {\n    console.error(`${r.id} has illegal status ${r.status}`);\n    process.exit(1);\n  }\n  if (r.status === \"CONFIRMED\" && !/REQUIREMENTS\\.md|instructor/i.test(r.evidence)) {\n    console.error(`${r.id} is CONFIRMED without evidence`);\n    process.exit(1);\n  }\n}\n\nconst guessed = rows.filter((r) => r.status === \"GUESSED\");\nconst sourceFiles = walk(ROOT);\nconst smellHits = [];\nfor (const file of sourceFiles) {\n  const txt = fs.readFileSync(file, \"utf8\");\n  for (const re of SMELLS) {\n    if (re.test(txt)) smellHits.push({ file, re: String(re) });\n  }\n}\n\nif (smellHits.length) {\n  console.error(\"Infrastructure smells need a CONFIRMED or REJECTED row, not hope.\");\n  for (const h of smellHits) console.error(`  ${h.file} ~ ${h.re}`);\n  process.exit(1);\n}\n\nif (guessed.length > MAX_GUESSED_IN_CODE) {\n  // GUESSED rows are allowed in the table. They are not allowed to drive code.\n  // The smell pack above is the cheap proxy for \"drove code.\"\n}\n\nconsole.log(\n  `ok: ${rows.length} rows, ${guessed.length} parked guesses, ${sourceFiles.length} source files`\n);\n```\n\nRun it like a unit test:\n\n```\nnode scripts/check-assumptions.mjs\necho $?   # 0 is the only passing grade for checkpoint 2\n```\n\nIs the smell list complete? No. It is a teaching fence. Students who rename `nodemailer`\n\nto `mailer.mjs`\n\nand call SMTP anyway still fail the human review. The script exists so the obvious fiction dies in CI.\n\nPaste `REQUIREMENTS.md`\n\ninto the repo. Do not edit it. If the agent rewrites the brief, that is an automatic zero for this checkpoint.\n\nWhy so strict? Because \"helpful\" rewrites are how a waitlist becomes a growth stack.\n\nFill `ASSUMPTIONS.md`\n\nwith at least three rows **before** `server.js`\n\ngains a route. Commit that file alone. I want a `git log`\n\nthat proves the log came first.\n\nAsk the agent a rude prompt and stop there:\n\n```\nRead REQUIREMENTS.md. List every assumption you would need\nto implement POST /waitlist. Tag each CONFIRMED, GUESSED,\nor REJECTED. Do not write code. Do not add dependencies.\n```\n\nIf it still opens a Prisma schema, you have a process bug, not a model bug.\n\nAdd `POST /waitlist`\n\n. Keep storage in a module-level array. Return 201. No extra packages unless a `CONFIRMED`\n\nrow names them. Then run the checker.\n\nA legal happy path looks boring. Boring is the point.\n\n```\n// server.js — labeled example for the lab, not a framework recommendation\nimport http from \"node:http\";\n\nconst waitlist = [];\n\nconst server = http.createServer(async (req, res) => {\n  if (req.method === \"POST\" && req.url === \"/waitlist\") {\n    const chunks = [];\n    for await (const c of req) chunks.push(c);\n    let body;\n    try {\n      body = JSON.parse(Buffer.concat(chunks).toString(\"utf8\"));\n    } catch {\n      res.writeHead(400, { \"content-type\": \"application/json\" });\n      res.end(JSON.stringify({ error: \"invalid_json\" }));\n      return;\n    }\n    if (typeof body?.email !== \"string\" || !body.email.includes(\"@\")) {\n      res.writeHead(400, { \"content-type\": \"application/json\" });\n      res.end(JSON.stringify({ error: \"invalid_email\" }));\n      return;\n    }\n    waitlist.push({ email: body.email, at: Date.now() });\n    res.writeHead(201, { \"content-type\": \"application/json\" });\n    res.end(JSON.stringify({ ok: true }));\n    return;\n  }\n  res.writeHead(404);\n  res.end();\n});\n\nserver.listen(3000);\n```\n\nDid that `@`\n\ncheck invent a validation rule? Yes. Park it as `GUESSED`\n\nor strip it. See how fast the habit shows up?\n\nSmoke test without extra libraries:\n\n```\nnode server.js &\ncurl -sS -D - -o /tmp/wl.json \\\n  -H 'content-type: application/json' \\\n  -d '{\"email\":\"dev@example.com\"}' \\\n  http://127.0.0.1:3000/waitlist\ncat /tmp/wl.json\n```\n\nYou want `201`\n\nand `{\"ok\":true}`\n\n. Anything else is either a broken student or a broken brief. Do not \"fix\" it by adding Redis.\n\nInstructor adds one sentence: duplicate emails return `409`\n\n. Students must:\n\n`GUESSED`\n\nto `CONFIRMED`\n\nwith 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.\n\n`CONFIRMED`\n\nrow that names the package.`REJECTED`\n\nrows into tests that must | Area | Points | Pass bar |\n|---|---|---|\nCheckpoint 0: unmodified `REQUIREMENTS.md`\n|\n10 | file hash matches the handout |\n| Checkpoint 1: assumption commit before route commit | 20 |\n`git log` order is checkable |\n| Table quality | 20 | ≥3 rows, legal statuses, `CONFIRMED` has evidence |\n| Checkpoint 2: checker exit 0 + 201 path | 25 | curl shown in the README |\n| Checkpoint 3: brief mutation reflected in table and code | 15 | 409 only after the table update |\n| Human review: no silent vendors | 10 | reviewer can name every extra import |\n\n**Automatic zeros:** rewriting the brief, committing secrets, adding a paid API call to \"finish\" the lab, or deleting the checker.\n\nI 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.\n\nThis is a teaching protocol. It is not an architecture review board.\n\n`https.request`\n\n. That is why 10 points stay human.`POST /waitlist`\n\nstill 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.\n\nThe agent will still guess. That is its job. Your job is to make the guess visible, cheap, and fireable.\n\nRun 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.\n\nSteal 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.", "url": "https://wpnews.pro/news/grade-the-guesses-a-bootcamp-lab-with-an-assumption-gate", "canonical_source": "https://dev.to/hackjs_7468/grade-the-guesses-a-bootcamp-lab-with-an-assumption-gate-42df", "published_at": "2026-09-04 06:37:57+00:00", "updated_at": "2026-09-04 06:53:40.182786+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-ethics"], "entities": [], "alternates": {"html": "https://wpnews.pro/news/grade-the-guesses-a-bootcamp-lab-with-an-assumption-gate", "markdown": "https://wpnews.pro/news/grade-the-guesses-a-bootcamp-lab-with-an-assumption-gate.md", "text": "https://wpnews.pro/news/grade-the-guesses-a-bootcamp-lab-with-an-assumption-gate.txt", "jsonld": "https://wpnews.pro/news/grade-the-guesses-a-bootcamp-lab-with-an-assumption-gate.jsonld"}}