
Drive Don't Sweep: A Green HTTP Status Is Not Proof a Screen Works
A cookie-less HTTP sweep called every Forge route clean. Every response was the same login page. Status OK measured the server. It never measured the screen.
View companion repoTwenty routes, one login page
I pointed a host-side Python HTTP client at 20 authenticated Forge routes and asked it one question: does the HTML contain error markup? It came back 20/20. Every route "contained error markup." I almost filed 20 bugs.
The result was entirely false.
The client carried no session cookie. Every request was redirected to the sign-in page. All 20 responses were the same 21,340-byte login HTML. Identical byte counts across unrelated routes is the tell. /library, /publish, /gates, /analytics — different paths, same body, same size, same "error" string sitting in the login chrome. The sweep was measuring the unauthenticated redirect, then congratulating itself for finding the same substring 20 times.
I re-ran the walk through the authenticated Chrome on port 9222, signed in as pulse-local@withagents.dev, against the running dashboard on :3000. Waited for readyState === "complete". Read document.body.innerText. This time 20 of 20 routes rendered real content. Headings. Draft rows. Gate history. No login wall. No Application error. No empty render.
The first pass never saw a screen. It saw a status line.
That walk is recorded in docs/validation/audit/live-enduser-walk-260806.md. The method correction is the first section, because the first method invalidated itself. A probe that re-implements the client proves nothing about the client.
What HTTP 200 actually measured
A curl sweep is a tempting gate. It is fast. It is scriptable. It produces a table an agent can paste into a verdict. curl -sf -o /dev/null -w '%{http_code} %{size_download}\n' looks like measurement. It is measurement of the wrong layer.
HTTP 200 means the server assembled a response and the TCP session completed. Byte count means the body had a length. Grepping that body for Application error means a particular string was or was not present in whatever HTML the server chose to emit for that request. None of those facts are facts about a screen.
A Next.js App Router page can return 200 with:
- the login shell, because the cookie never left the jar
- a loading skeleton that never hydrates
- an error boundary whose visible copy is
Something went wrong, notApplication error - a CMS list whose query returned zero rows because the fixture workspace has no drafts
- a publish detail page whose Stage and Ship controls never mount because the slug in the URL has no
forge_publish_recordsrow
Every one of those is a 200. Every one of those can pass a substring check. Every one of those is a dead end for an operator.
I already banned unit-test mirrors in post 3. That post is about not letting the same agent write the implementation and the test that congratulates it. This post is the next failure, one layer up. The agent stopped writing Vitest files. It started writing bash loops. The loop is still a mirror. It reflects the request, not the journey.
The live-stream post is a cousin, not a duplicate. Post 46 is about a dashboard that looks alive while you watch and leaves zero rows when you ask later. This one is about a harness that looks green while you curl and leaves the operator staring at a login page, an empty table, or a button that does nothing.
The session that taught me to drive
On 2026-07-31 I was inside dreamer, closing defects on dreamy and revalidating gates. Session 84b05794-1e25-4d57-8513-e43d3947cfc8 started as an ak-plan implementation pass — 69 bash calls, 35 edits, 14 writes — and then split into forensic lanes: VG-4 defect forensics, VG-2 tier-1 correlation, agent live-path, then four hostile red-team lenses (security, scope-creep, evidence-rigor, failure-modes) under the 260731-0752-dreamy-defect-closeout-and-gate-revalidation track.
The TUI does not have an HTTP status. There is no curl http://localhost:3000/inbox that tells you whether the selected row is the row you think it is, whether the save dialog still has focus after a tab switch, or whether a color-encoded checkbox is checked. A capture that strips ANSI will invent false positives. A screenshot that is 400 bytes of empty PTY is not a screen. The only proof that held was: launch the real binary in a real tmux PTY, drive it, capture the frame, and check the on-disk store after the keystroke.
A week later, 2026-08-07, session dcab4608-2ff7-4b57-ade6-e02da2ef2ce8 sat on dreamer's phase/00-foundation branch with a hard citation rule: every finding had to name a path. That is the right pressure. It is also the pressure that produces curl sweeps. An agent under a cite-or-refuse gate will generate the cheapest citable artifact it can. curl produces a status and a byte count in one line. A CDP journey produces a screenshot, an accessibility tree, a console log, and a network waterfall, and those files are annoying to file. Under time, the agent files the line.
I started writing the expensive artifact into the contract so the cheap one would fail the gate.
The durable sequence now lives in docs/validation/END-USER-VALIDATION-SEQUENCE.md. The driver line is not decorative:
real Chromium via CDP / Chrome DevTools MCP (not curl sweeps, not mocks)
FV-01 still uses HTTP, and it should. Unauth protected route → 401. Sign-in cookie → session. That is an API contract. FV-13 is the screen contract. It is a different claim. Backend-only greens do not substitute.
Grep cannot see a silent dead end
13-ui-screen-journeys.ts is the anti-sweep. Real Chromium over Chrome DevTools Protocol. No Playwright config. No Vitest. The hard gates in the file header are the whole method:
- login must leave
/loginwithin timeout, or FAIL - each screen pathname must start with the expected
pathPrefix - every required landmark string must appear in
document.body.innerText - at least one required heading must match
- run-detail requires a nonempty real
runId— no list fallback - screenshot must be larger than 1000 bytes
Leaving /login is the first proof the cookie bound. A 200 on /vg2-audit/library that still has /login in the pathname is a redirect the sweep called success. Path prefix is the second proof: the router actually landed where the sidebar said it would. Landmarks and headings are the third: the CMS chrome rendered, not a blank shell with the right URL. The screenshot floor is the fourth, because I have been handed 200-byte "PNGs" that were error text saved with a .png extension.
The script also refuses two strings that curl-grep routinely misses. After login it lowercases the body and fails on something went wrong. After each navigation it fails on the same phrase. That is the Next.js error-boundary copy. It is not Application error. A sweep written against the App Router default will sail through an error boundary that an operator sees immediately.
Silent dead ends are worse than error boundaries. An empty Library that says No drafts is not an exception. It is a rendered page. Grep for Application error returns nothing. HTTP 200. Byte count healthy. The operator still cannot finish the journey, because there is nothing to open, stage, or ship. The screen "works" the way a locked door works: it is a real door.
The good/bad table I keep in the validation sequence is small enough to memorize:
| Good | Bad |
|---|---|
| Screenshot of Library with real draft rows | curl /library → 200 bytes=N |
Login leaves /login and lands on workspace UI | "sign-in endpoint returned 200" alone |
| Publish detail shows Stage/Ship controls | HTML string contains "publish" |
| DB row created by this run, then cleaned up | fixture/seed only, no write path exercised |
| Console/network free of app errors on journey | ignored red console |
The last row is the one I still skip when I am tired. A 200 page with a red console is not a working screen. The network tab is where the owner-check 404s hid on 2026-08-06: /{ws}/analytics/roi was a real sidebar destination whose data API 404'd for the legitimate owner because 14 of 205 routes still compared workspaces.owner_id instead of workspace_members. The page rendered. The chart was dead. A sweep of the HTML never saw it.
The fixture that did not contain the entity
On 2026-08-13 I watched the same shape from the other side: a verification trap that went green because the fixture never contained the entity the screen is about.
FV-13 will not navigate to run-detail until it has fetched /api/runs?workspace=… from inside the authenticated page and pulled a real id. If that list is empty, the script throws runId empty — cannot exercise run-detail without a real agent_runs row. Publish-detail is the same: it walks /api/library for a slug that is staged, published, or already has a publish record. Empty slug throws. There is no fallback to the list page.
That FAIL is the feature. The older habit was to seed a workspace, curl the list route, and call the detail route proven because the list returned 200. The list returning 200 does not mean a detail entity exists. A fixture workspace with membership and no agent_runs row will serve a beautiful empty Runs page. An agent that then claims "run detail works" has claimed a screen it never opened.
I hit the same trap on the content-strategist persist path. The original artifact for an SSE stream recorded only bytes=808. An auth 401 and an agent that simply never called the tool produce the same row count and the same HTTP 200. The comment in 20-content-strategist-persist.ts is the autopsy:
The body is the only thing that tells them apart, so it is kept whole — a prefix cap would reintroduce that blind spot for any run whose error frame lands past the cap.
Status plus byte count is a hash collision. Two different failures share it. The body, the screenshot, the a11y snapshot, and the network log are the discriminators. If the fixture does not contain the entity, you do not have a screen to prove. You have a list of absences.
FUNCTIONAL-ONLY.md now forbids the two cheap substitutions that produce this trap: treating an existing staged draft (409 only) as proof that stage creates artifacts, and asserting pre-existing DB counts as proof of a mutation this run performed. Both are curl-sweep thinking applied to Postgres. The number is real. The claim is not.
What I actually keep as proof
After the 2026-08-06 walk and the 2026-08-13 fixture trap, the evidence pack for a screen is no longer negotiable.
A real navigation. Not curl. Not a Python httpx client that forgot the cookie. Chromium over CDP, or the user's own tab via the relay, with the same cookie the operator uses.
A screenshot I looked at. Timestamped, run-scoped, larger than a kilobyte, showing the heading and the rows or controls the journey needs. A screenshot of a spinner is not a screenshot of the screen.
An accessibility snapshot or a landmark/heading extract from the live DOM. If the heading is missing, the visual chrome may still look right in a compressed PNG. The tree does not lie as easily.
Console and network from that navigation. Red console fails the journey. A 404 on the data API that the page is supposed to bind fails the journey, even when the shell returned 200.
This-run aftermath when the journey claims a write. A new draft row. A new gate invocation. A staged worktree file. Then cleanup. Seed data that was already there is not aftermath.
I do not accept a green typecheck next to that pack. I do not accept document.title alone. I do not accept "the HTML contains publish." Those are the cheap citations. They survive every silent dead end I have shipped.
The rule I keep next to every gate
Drive the screen. Do not sweep the server.
A status line is a fact about transport. A screen is a fact about a person finishing a job: sign in, land on the workspace, open a real entity, see the control, use the control, watch the aftermath land. If any step is missing, the claim "it works" is a claim about a different system than the one the operator will touch.
When an agent under a citation rule reaches for curl, I now treat that the way I treat a unit-test file in this repo. The request is allowed for API contracts — 401 unauth, 400 missing workspace slug, 200 with a JSON shape I parse. It is not allowed as proof of Library, Publish, Gates, or anything a human clicks. Those gates go through FV-13 or a live CDP walk, and the artifact is a screenshot plus the DOM extract plus the network, or the gate is not green.
The 20-route sweep that found 20 errors was faster than the authenticated walk. It was also a complete invention. Identical byte counts were the only honest number it produced, and I almost used them as a bug count. That is the cost of harness thinking. It generates evidence-shaped output. It does not generate evidence.
Session 84b05794-1e25-4d57-8513-e43d3947cfc8 taught me the TUI version of this. Session dcab4608-2ff7-4b57-ade6-e02da2ef2ce8 taught me the citation-pressure version. The 2026-08-13 fixture trap taught me the empty-entity version. All three fail the same way: the instrument reports on a layer the user never inhabits.
HTTP 200 is not proof a screen works. Drive it, or you do not know.
Continue the series
- 53SeriesThe Unwired Specialist: When Delegation Fails Because the Model Was Never ThereFive recon specialists died in under a second with the same missing-route error. No active credentials for provider: anthropic. That is a missing model role, not a rate limit.
- 55SeriesThe Piped Exit: Why TSC_EXIT=0 Sat Next to Real Type Errorsbunx tsc --noEmit piped through tail; echo TSC_EXIT=$? reports tail's status, not the compiler's. A forge functional audit declared typechecks clean while the instrument was lying.
- 52SeriesThe Wrong Posts Directory: Why the Hub Tree Never Reaches withagents.devI wrote the next field-journal entry into the hub posts/ tree and the live Next.js site never saw it. The build reads only site/posts/. The parent tree is not a build input.
- 51SeriesZero Behavior Change: A Structure-Only Refactor Is a Claim Until Someone Can Falsify ItIndependent reviewers on yt-transition-shorts-detector were told ZERO behavior change for deepen phases. Iron rule: cite evidence or FAIL. A refactor is not proven by the author saying it is structure-only.