In a world with , chapter 12.1.
THE PATH · SESSION 62 OF 66 · UNIT 12 · ACT III
Sprint 1 — Foundation
Reconcile the plan with the app that already runs
At a glance
- PACE
- Session 62 of 66 · suggested week 21. Continue when you are ready.
- UNIT
- Unit 12 · The Final Boss Was a Live Demo and I Won
- ACT
- Act III · The kingdom
- BEFORE
- 11.4 War Council
- AFTER
- 12.2 Sprint 2 — Build
- ARTIFACT
- A capstone foundation with one complete vertical slice and an architecture record
- READ
- 55 min · 3 diagrams · 16 code blocks
The hook#
At the start of the path, you opened a terminal for the first time. Today you open ~/projects/<capstone-name>, and it is not empty. It has the research with its empathy map, the user stories, the sprint plan, and the product spec from Unit 8 in docs/, and a GitHub Projects board whose Sprint 1 column has never been emptied. It has the architecture record from 9-5. It has sign-in, a Stripe test checkout from 10-2, an AI feature from 10-3, Helmet and a rate limiter and validators from 10-1, no tests yet, because Unit 7 wrote its suite against the hall and Sprint 3 brings the same pattern here, and it has been live on a real URL since 10-4. The final unit does not start from scratch. It finishes.
That sentence hides the real problem. The app gained its working foundations in Unit 10 and stayed beside you through Unit 11, while the planning documents reach back to Unit 8, and the honest answer to "which Must-haves are done?" is a shrug. Some of them work on the deployed URL. Some work on your laptop and nowhere else. Some have a route and no screen. Some have a screen that saves and then forgets on refresh. Sprint 1 turns the shrug into a table, picks the Must-have that is furthest from done, and finishes it as one complete slice: a form, a route, validation, a saved record, and the refresh that shows it. By the end of the session the plan and the app agree, and you have written down, once, what demo day actually asks of you.
You already crossed the river. Sprint 1 is turning around and counting the stones you stepped on.
The idea#
Unit 8 left you with four files in docs/: research.md with the interview logs for P0 through P3 and the combined empathy map, user-stories.md, sprint-plan.md, and product-spec.md at v1.1, revised before this session. The spec sorts your stories into Must, Should, Could, and Won't. The sprint plan slices them into four sprints mapped to the four build sessions of this unit, each with hours, capacity, and slack, and its Sprint 1 scope reads "models and seed, auth, one resource end to end, pages stubbed." Most of that happened in Units 9 and 10, which is why today is a reconciliation rather than a build. The plan was a prediction. Now the app is the fact. Sprint 1 is reconciliation: for every Must-have, you find out which of four states it is in, and you write that state down next to the story. Deployed and working. Working locally only. Partly built. Not started. Nothing else counts as a status. "Mostly done" is not one of the four, because a reviewer cannot click on "mostly."
The second idea is the vertical slice. A feature is complete when one user action travels the whole way: a form the user can reach, a route that receives it, validation that rejects the bad case, a record that lands in MongoDB, and a reload that shows the saved record. Five parts. If any one is missing, the slice is not finished, however good the other four look. The most common missing part is the last one. The form sends, the UI updates, you move on, and nobody refreshes the page. On refresh the record is gone, because the client updated its own state and the route returned 500 behind it.
The third idea is that a sprint is a list you can finish. Unit 8 taught capacity. This unit has four build sessions and a final demo session. Each build session is a sprint with the time budget you set in 8.3; it may take more than one sitting. A Must-have list that needs ten build sessions will not fit a four-session plan unless you cut scope or deliberately extend the plan. Sprint 1 is where you cut, and the rule for cutting is coherence: the kept pieces must belong to one path a person can walk from sign-in to the valuable outcome your empathy map named. A sign-in with no feature behind it plus a checkout with no product in front of it is two fragments, not an MVP.
Napkin version: open the spec, grade every Must-have into one of four states, pick the worst one, finish it as a five-part slice with a seeded account, and update the plan so it describes the app. Then write the demo-day contract.

The four states, and how to find them without lying#
You grade a Must-have by using it, not by reading the code. Open the deployed URL in a private window with no session. Walk the story the way the user story is written: "As a signed-in member, I can create a listing and see it on my dashboard." If you can do that on the live URL, it is deployed. If the live URL fails and localhost succeeds, it is local. If a route exists but the screen does not, or the screen exists but the route 404s, it is partial. If nothing exists, it is missing.
The private window matters. Your normal browser has capstoneToken in sessionStorage from 10-1 and a cached bundle. A fresh window is the first-time user, and the first-time user is the one the demo video will show.
Write the result into docs/sprint-plan.md under a new heading. Copy this into the file and fill it in:
## Sprint 1 — status on <date>, deployed commit <short sha> | Story (Must) | State | Evidence | Missing part ||---|---|---|---|| | deployed / local / partial / missing | what you clicked and what happened | form / route / validation / persistence / refresh |A filled row looks like this:
| M1 Sign in with email and password | deployed | logged in as demo@example.com on https://YOUR-APP.up.railway.app, /api/me returned my id | none || M2 Create an entry and see it after reload | partial | POST /api/entries returns 201 locally; dashboard shows it until refresh, then empty | refresh || M3 Upgrade with test checkout | local | Stripe test card works on localhost; live URL returns 500 (webhook secret missing) | persistence |The last column is the one that makes the table useful. "Partial" tells you nothing. "Partial, missing refresh" tells you where the bug is: the client is painting its own state and the server is not returning what it saved.
What a slice has to prove#
Walk the five parts for one story and keep a finger on each.
The form is a controlled component in client/src, the shape you built in 4-11. It holds a draft, it has a button, and it calls a function in the parent when it has a trimmed, non-empty value. The route is an Express handler mounted under /api, behind the bearer-token middleware from 4-9 and 10-1, in api/. Validation runs on the server with the express-validator chains from 10-1, and it answers 400 with { "error": "..." }, the body shape every route has used since 4-12. Persistence is a Mongoose create or save on a model that has an owner field set from req.userId, never from the body. The refresh is GET /api/<resource> filtered by owner, called from an effect on mount, so a reload paints what the database holds and not what the client remembers.
The failure path is the sixth thing, and it is part of the slice. If the save fails, the draft stays in the field and the error is visible. If validation rejects the input, nothing is written. If the user is signed out, the route answers 401 before it touches the database. You built each of these once in Unit 4 and again in Unit 10. Today you check that your capstone's most important story has all of them, on the deployed URL, not on a diagram.
Folders, ports, and the two halves#
For this unit, api/ is the Express half and client/ is the Vite React half of ~/projects/<capstone-name>, the layout 10-1 scaffolded and the one railway.json from 10-4 builds. If your capstone keeps the API at the repository root, drop the cd api from the commands and nothing else changes. The API listens on port 3000, the port it has used since 10-1. The Vite dev server is 5173. The hall-api on 4100 is a different project, and only one server runs at a time: stop anything else with Ctrl-C in its terminal before you start the capstone.
Secrets live in api/.env, read by node --env-file=.env server.js, the way 4-6 taught, or through dotenv if you took the library option in 10-1. Either way, the names in .env are the names the deploy needs, and the deploy has no way to read your laptop. That is why this sprint brings api/.env.example, the file 10-1 started with four names, up to date: the same keys as .env, empty values, so the README can say "copy this and fill it in" and mean it.
The fixture account is a product decision#
Every demo, every test, and every screenshot in this unit uses one account you create on purpose. Pick demo@example.com with a password you write in the README's development section, because this account never exists in a real user's hands. Seed it with a script, not by hand, so a fresh database on a fresh machine gets the same account and the same three or four records. scripts/seed.js connects with the same MONGODB_URI the server uses, deletes the fixture account's records, and re-creates them. It never touches other accounts. Re-running it is how you reset before a recording.
The demo-day contract, written once#
Demo day in this path is a solo event, and here is what it asks of you, so every later sprint is aimed at the same target. In 12-5 you deliver four things: a recorded five-minute demo of the deployed URL using the fixture account; a written Q&A, five questions a reviewer would ask, answered from your implementation; a self-scored 100-point rubric; and a handoff packet in docs/demo-day.md another developer could operate from. The pitch deck or the stakeholder brief from 11-4, whichever road you took in Unit 11, and the financial narrative sit next to those, updated to the product you actually shipped. Nothing on that list requires an audience in a room. All of it requires the app to work on the live URL.
The demonstration#
Carried over from Unit 10, and assumed by every command in this unit: the capstone is ~/projects/<capstone-name> with api/ and client/ side by side. api/app.js exports { app }, and api/server.js connects to Mongo and listens, printing listening on 3000. Every module in api/ exports an object, so a require takes braces: { User } from api/models/user.js, { requireUser } from api/middleware/require-user.js, { validateRequest } from api/middleware/validate.js, { router } from each file in api/routes/. requireUser sets req.userId, one name, set in one place. On the client, client/src/api.js exports apiBase, read from VITE_API_BASE with http://localhost:3000 as the development fallback; 10-4 set it to the empty string for production, which means same origin. The same file keeps the token in sessionStorage under the key capstoneToken, the capstone's version of 4-12's slateToken, behind getToken, setToken, and clearToken. api/.env holds the names 10-4 deployed: PORT, MONGODB_URI, JWT_SECRET, PAGE_ORIGIN, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_ID, ANTHROPIC_API_KEY, AI_MODEL, AI_DAILY_TOKEN_BUDGET, AI_ENABLED, and NODE_ENV. The fixture account is demo@example.com. Where your capstone uses a different name, the sprints still use the Unit 10 name, and you translate.
One worked example, on a capstone whose Must-have M2 is "a signed-in member creates an entry and sees it after reload." Your resource has a different name. The shape of the work is the same.
Start in the repository and look at what is actually there before touching anything.
cd ~/projects/<capstone-name>git status --shortgit log --oneline -5ls docs M client/src/App.jsxa41c9e2 deploy: production webhook url7d02f1b ai: timeout fallback...ai-feature.md funding-route.md pitch-deck.md runway.js threat-model.mdboard-sprint-1.png fundraising-research.md pitch-deck.pdf security-audit.md user-stories.mdcap-table.md payments.md product-spec.md sprint-plan.mdfinancial-narrative.md pitch research.md system-design.mdThat listing is the Noble Houses set from Unit 11. You see one of two sets, and docs/funding-route.md says which: on the Village road, cap-table.md, pitch-deck.md, and pitch-deck.pdf read ownership.md, stakeholder-brief.md, and stakeholder-brief.pdf, and operating-budget.md sits beside them. funding-route.md, financial-narrative.md, runway.js, and the pitch/ folder are in both.
A modified file you do not remember is the first finding. Read the diff, then commit it or git checkout -- client/src/App.jsx. Sprint 1 starts from a clean tree, because the deployed commit is the thing you are grading against, and a dirty tree is a tree nobody deployed.
Open the private window and grade. For M2 on the live URL: sign in as demo@example.com, create an entry, watch it appear, press reload. It is gone. Now the same on localhost. Stop any other server with Ctrl-C, then:
cd ~/projects/<capstone-name>/apinode --env-file=.env server.jslistening on 3000In a second terminal, the route by hand, with a token from the login route.
TOKEN=$(curl -s -H 'Content-Type: application/json' \ -d '{"email":"demo@example.com","password":"demo-pass-1"}' \ http://localhost:3000/api/login | node -p 'JSON.parse(require("fs").readFileSync(0)).token')curl -s -i -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"title":"First entry","body":"It works on my machine"}' \ http://localhost:3000/api/entriesHTTP/1.1 201 CreatedContent-Type: application/json; charset=utf-8 {"_id":"6726...","title":"First entry","body":"It works on my machine","owner":"6701..."}Then the read-back that the refresh depends on:
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/entries[]There it is. The create works and the list is empty. That is a partial slice with the persistence or refresh part missing, and the grade in the table is partial. Open the router. The GET handler filters by owner: req.user, and the POST handler saved owner: req.userId. Two names for the same claim. requireUser has set req.userId since 4-9, and nothing on this path has ever set req.user, so the filter carries undefined and matches no saved entry. Pick the name the middleware sets, req.userId, and use it in both handlers. While the file is open, make it the shape 10-1 taught: a chain per field, validateRequest in front of the handler, and matchedData instead of req.body.
// api/routes/entries.jsconst express = require('express');const { body, matchedData } = require('express-validator');const { Entry } = require('../models/entry');const { requireUser } = require('../middleware/require-user');const { validateRequest } = require('../middleware/validate'); const router = express.Router(); const entryRules = [ body('title').isString().trim().isLength({ min: 1, max: 120 }).withMessage('title is 1 to 120 characters'), body('body').isString().trim().isLength({ max: 5000 }).withMessage('body is at most 5000 characters')]; router.get('/', requireUser, async (req, res) => { const entries = await Entry.find({ owner: req.userId }).sort({ createdAt: -1 }); res.json(entries);}); router.post('/', requireUser, entryRules, validateRequest, async (req, res) => { const { title, body } = matchedData(req); const entry = await Entry.create({ title, body, owner: req.userId }); res.status(201).json(entry);}); module.exports = { router };The capstone is CommonJS, the way 10-1 scaffolded it, so require is the right spelling here, and every require takes braces because 10-1 exported objects, { Entry } and { requireUser }, not bare values. api/models/entry.js follows user.js: a schema with title, body, an owner of type ObjectId with ref: 'User', timestamps, and module.exports = { Entry }. In api/app.js the router mounts beside the others: const entries = require('./routes/entries'); at the top and app.use('/api/entries', entries.router); under app.use('/api', auth.router);. Restart, run the two curls again, and the second one returns an array with one entry. Now the browser: reload the dashboard and the entry is there. The refresh agrees. The slice is complete, and the table row changes from partial to local.
It becomes deployed only after the fix is live, and that is 12-3's job, not today's. Today you commit it and note it. The seed script comes next, because the fix is useless without an account to demonstrate it with.
// api/scripts/seed.jsconst mongoose = require('mongoose');const bcrypt = require('bcrypt');const { User } = require('../models/user');const { Entry } = require('../models/entry'); async function main() { await mongoose.connect(process.env.MONGODB_URI); const email = process.argv[2] || 'demo@example.com'; const existing = await User.findOne({ email: email }); if (existing) { await Entry.deleteMany({ owner: existing._id }); } const passwordHash = await bcrypt.hash('demo-pass-1', 12); const user = await User.findOneAndUpdate( { email: email }, { name: 'Demo', email: email, passwordHash: passwordHash }, { new: true, upsert: true } ); await Entry.create([ { title: 'Scout the ridge', body: 'Three hours, clear sky.', owner: user._id }, { title: 'Copy the map', body: 'The west edge is torn.', owner: user._id }, { title: 'Relieve Ivo', body: 'He has been on the wall since dawn.', owner: user._id } ]); console.log('seeded ' + email + ' with 3 entries'); await mongoose.disconnect();} main().catch(function (err) { console.error(err.message); process.exit(1);});The order matters. The entries are deleted by the id the account already has, before anything touches the account, because a seed that deletes the user first and creates a new one gets a new _id, and the old entries are left behind under an id nothing can find. The upsert keeps the same _id across runs and only refreshes the hash, which is also why the token you fetched before a reseed still works. name is there because 10-1's schema requires it, and the optional email argument is what 12-3 uses to seed a second account. The script reads MONGODB_URI from process.env directly rather than through config.js, so it can run against a database with only that one variable set.
cd ~/projects/<capstone-name>/apinode --env-file=.env scripts/seed.jsseeded demo@example.com with 3 entriesAdd "seed": "node --env-file=.env scripts/seed.js" to the scripts in api/package.json so the README can say npm run seed. Then the example env file:
# api/.env.example: copy to api/.env and fill in every valuePORT=3000MONGODB_URI=JWT_SECRET=PAGE_ORIGIN=http://localhost:5173STRIPE_SECRET_KEY=STRIPE_WEBHOOK_SECRET=STRIPE_PRICE_ID=ANTHROPIC_API_KEY=AI_MODEL=claude-haiku-5-5AI_DAILY_TOKEN_BUDGET=50000AI_ENABLED=trueNODE_ENV=developmentOnly the keys your server actually reads. Grep for process.env. in api/ and make the list match. A key the README promises and the code never reads is a lie that costs a reviewer twenty minutes.
Here is a small model you can run here. It grades a status table the way the session does and checks a slice the way the demonstration did. It proves that the grading rules are mechanical and that a save without a refresh is not a finished slice. It does not open your repository, hit your deploy, or know your stories, so the real table is still yours to fill.
function check(condition, message) { if (!condition) throw new Error(message); console.log('PASS:', message);} const STATES = ['deployed', 'local', 'partial', 'missing']; function grade(row) { if (row.live) return 'deployed'; if (row.localOk) return 'local'; if (row.route || row.screen) return 'partial'; return 'missing';} const stories = [ { id: 'M1', title: 'sign in', live: true, localOk: true, route: true, screen: true }, { id: 'M2', title: 'create entry, see after reload', live: false, localOk: false, route: true, screen: true }, { id: 'M3', title: 'upgrade with test checkout', live: false, localOk: true, route: true, screen: true }, { id: 'M4', title: 'export ledger', live: false, localOk: false, route: false, screen: false }]; const graded = stories.map(function (s) { return { id: s.id, state: grade(s) }; });check(graded.every(function (g) { return STATES.indexOf(g.state) !== -1; }), 'every story lands in one of four states');check(graded[0].state === 'deployed' && graded[3].state === 'missing', 'live wins over local, nothing built is missing'); const order = { missing: 0, partial: 1, local: 2, deployed: 3 };const worst = graded.slice().sort(function (a, b) { return order[a.state] - order[b.state]; })[0];check(worst.id === 'M4', 'the sprint starts with the Must-have furthest from done'); const slice = { form: true, route: true, validation: true, persistence: true, refresh: false };const incomplete = Object.keys(slice).filter(function (key) { return !slice[key]; });check(incomplete.length === 1 && incomplete[0] === 'refresh', 'A visual save is not yet a complete slice'); const fixed = Object.assign({}, slice, { refresh: true });check(Object.keys(fixed).every(function (key) { return fixed[key]; }), 'a slice with the refresh is complete'); const owners = { post: 'userId', get: 'userId' };check(owners.post === owners.get, 'the create and the list read the same claim'); console.log('ok: checkpoint-12-1');Where it breaks#
Your turn#
The quest#

The deliverable is the Sprint 1 section of docs/sprint-plan.md, with the status table filled from the live URL and the sprint's one finished slice recorded under it. Under the table, write the demo-day contract in your own words: the four things 12-5 collects and the URL and account the demo will use.
Evidence to keep in the repository:
docs/sprint-plan.mdwith the Sprint 1 status table, the commit sha it was graded against, and every cut explained.- The two curl commands and their output for the finished slice, pasted under the table as a
textblock. - A screenshot named
docs/evidence/12-1-refresh.pngof the dashboard after a reload, showing the record you created. api/scripts/seed.js,npm run seedinapi/package.json, andapi/.env.example.- The updated architecture record with one sentence per box.
- One commit with a message that names the story:
sprint 1: finish M2 create-and-reload slice.
Side quests#
SOLID is the vocabulary for the "what breaks if I delete this" question you just asked of every box, and the system-design primer is where the client, server, and persistence boundaries you reconciled today were first drawn.
Side quests from this session
- SOLID and Friends (JS flavored)SOLID gives you the words for the question you asked of every box in the architecture record today: what breaks if this is deleted, and what is it the only thing responsible for.System Design
- System design primer: building your digital mansionThe system-design primer is where the client, server, and persistence boundaries you reconciled against the live URL were first drawn, and it is the sheet to reread before you defend each one on demo day.System Design
Before next session#
Sprint 2 builds the Should-haves that survived the cut, and it gives the core flow an explicit state machine so a double click, a failed save, and an empty list each do one predictable thing. Before then, finish the rows that were local and not deployed if the fix is small, and leave the deploy itself for 12-3. Read Reacting to Input with State once more with your form open; next session the status string from 4-11 grows into the thing that decides what the button does.
Nothing stays running between sessions. Stop the server with Ctrl-C. Your deployed URL stays up on its own, which is the point of having one.
See also