Skip to content
← Back to the build log

Part 7 · 8 September 2026

Four bugs, four instruments

The client milestone ran overnight: nine tasks, nine reviews, three green CI runs, a mergeable pull request. Four defects got through anyway, and each one was visible to exactly one instrument. Unit tests and reviews saw none of them.

React Playwright Docker AI agents

series · Rebuilding a school-ops dashboard

Until this week every screen in the dashboard was a proof that the API contract worked. Two screens, nine files, a page that said “You are Aaron, role manager” and a month table under it. This milestone is the real client: a teacher opens a schedule on a phone, files a lesson from a card, reads their month and confirms it. A manager reads the draft with its warnings, clicks a teacher’s name to see the app as that teacher, and finalises the month.

I brainstormed it on Sunday evening in six questions, wrote the spec, wrote a nine-task plan, and let the plan run overnight with a fresh AI agent per task and a different agent reviewing each one. Twenty commits by four in the morning. Pull request mergeable, CI green three times over. I merged it at breakfast.

Four bugs reached that merge. Each one was visible to exactly one instrument, and the two instruments most projects trust, unit tests and code review, saw none of the four.

The loop only a browser could see

View-as is the feature I care most about in this project. A manager sends a header naming a teacher, and the API answers as if the teacher had asked. The last milestone made the database refuse any write under that header. This one gave it a screen: click a name in the draft, and every teacher page renders for that person with a banner across the top that says “Viewing as Demo Teacher. Read only.”

The router nests the teacher pages under a route called /as/:personId, and that route is guarded so only a manager can enter it. The guard reads the signed-in user’s role from /api/me. Under view-as, /api/me returns the viewed teacher.

You can see the loop coming. The manager clicks the name. The view-as route mounts, sets the header, and asks /api/me again. The answer says “teacher”. The guard around the route says a teacher may not be here and swaps in the “not allowed” page. The view-as route unmounts, clears the header, and asks /api/me once more. The answer says “manager”. The guard lets the route back in. It mounts, sets the header, asks again.

Nineteen unit tests covered the shell, the guard and the view-as route by then, and all nineteen passed. Each one rendered a single piece with a fake /api/me that returned one fixed answer. None of them could return two different answers to the same question a hundred milliseconds apart, because no unit test has a reason to. The task review read the guard and the route separately and approved both. The first thing that ran them together against the real API was the browser test in task eight, and it hung on a page that mounted and unmounted itself until the timeout.

The fix is one line: gate on the real role, which the API sends alongside the impersonated one. Then a second reviewer read the fix and pointed out that the grants half of the same check still read the viewed person’s grants. A manager viewing as someone with the HR grant would have carried that grant into any page gated by it. No such page exists today, so the hole was theoretical, and it went in the same fix. The final review unified the four places that read identity behind one function, so that the next person who writes a guard cannot choose the wrong half.

The order only CI could see

Task two moved a helper that mints a signed session cookie into a shared package, so the browser tests and the API tests could both sign in without touching Google. The package typechecked. The package’s own tests passed. Eight more tasks ran on top of it, each one running the full local gate before committing.

The first CI run of the branch failed at the typecheck step, in that package, with a missing module.

The monorepo runs typecheck across every package in dependency order. The new package depends on the database package, and the database package must be compiled before anything can import its types. On my machine the compiled output had been sitting on disk since the day before, left there by an earlier build, so every local typecheck found it. CI starts from nothing. It reached the new package before anything had compiled its dependency, because the only step that compiled it belonged to the API package, further down the order.

Two lines fixed it: a pre-hook on the package that builds its dependency first. The lesson is older than this project. A green local run proves your machine’s state, and your machine’s state includes everything you did yesterday.

The image only a reproduced build could see

By the ninth task the branch had a whole-branch review pending, on the strongest model I can run, with the spec, the plan, the execution ledger and a diff of nine hundred kilobytes. It came back with one Critical finding, and the finding was not in the diff.

The web app builds into a static bundle, then a Dockerfile packages that bundle behind a small web server. The Dockerfile copies two folders into its build context: the web app and the API contract. Task eight had added a Playwright folder to the web app, and that folder imports two more workspace packages. It had also told TypeScript to typecheck that folder, and the build script ran TypeScript across every folder before bundling.

So docker build would copy the web app, find the Playwright folder, try to typecheck it, and fail to resolve two packages that were never copied. The reviewer did not infer this. It rebuilt the image’s build context from git archive, ran the install and the build in a scratch directory, and pasted the error: TS2307: Cannot find module '@minim/db'.

CI had not caught it because CI had no build step. It ran lint, typecheck, unit tests, the API tests, the contract drift check and now the browser suite. Every one of those compiles code. None of them builds an image. The fix was to typecheck only the app’s own project in the build script, and to add pnpm build to CI so the surface at least joined the gate.

The image only the deploy could see

It did not join the gate. pnpm build compiles the packages. It does not run a Dockerfile, and there were two Dockerfiles.

The deploy runbook for this milestone is four steps: pull, rebuild both images, restart, check by hand through a tunnel. I ran step two over SSH at ten in the morning and watched the API image fail:

ERR_PNPM_WORKSPACE_PKG_NOT_FOUND
In apps/api: "@minim/test-support@workspace:*" is in the dependencies
but no package named "@minim/test-support" is present in the workspace

Same defect, one Dockerfile over. The API’s package file now named the shared test package, as a development dependency that the production image strips out at the end. The install step refuses to start when a named workspace package is missing from the build context, and the API’s Dockerfile copied the two packages it had needed in August.

One COPY line. Then, because I had already been wrong once about what covered this, CI now runs docker compose build api web after the compile step. Both images, every push. The run takes four minutes longer and I do not intend to argue with it.

Two clocks

The browser suite deserves its own paragraph, because it is the instrument that found the first bug and it took some care to make honest.

The tests drive the real API and a seeded Postgres from Chromium. Sign-in is a cookie the shared helper mints and drops into the browser, since Google will not play along with a test runner. The demo data lives in September 2026, with a lesson on Wednesday the second, so the tests have to believe it is that week. Two clocks needed freezing. The browser’s, through Playwright’s clock API, which fixes Date.now() at an instant I chose. And the API’s, which now honours an environment variable that names an instant.

A variable that freezes a server’s clock is the kind of thing you do not want reaching production. It has three barriers. The code ignores it when the environment says production. The runtime image sets that environment on the line after the base image. And the compose file passes the API an explicit list of six variables, so a seventh in the box’s .env never arrives. The reviewer checked all three, which is more than I did when I wrote it.

Three journeys run in CI under Tokyo time and under UTC. A teacher in a phone-sized viewport opens the schedule, files the Wednesday lesson from its card, watches the card turn to “complete”, opens the month, sees the line, confirms with a note and reads back the timestamp. A manager sees a pending expense as a warning, clicks the teacher, sees the banner, finds no confirm button, exits, tries to finalise and reads the refusal from the API, then finalises once the expense is cleared. A teacher who types a manager’s URL gets the “not allowed” page, and the API returns 403 to the same request without the page.

That last journey found something too, though it was in the test rather than the app. The plan said a pending expense would show a ¥900 line on the teacher’s month. It does not. A pending expense produces a warning and no money, which is what the last milestone’s domain code has always done. The assertion came out before the test was written.

The smaller ones

The filing form had five field groups, one per attendance type, and a schema for each that mirrored the API’s validation by hand. It rendered no field errors at all. Submit with no class chosen and nothing happened: no message, no request, no change. The plan had told the implementer to put server validation errors on a single line under the form, and the implementer did exactly that, so the task review flagged it as plan-mandated. The spec said errors go under their fields. The spec won, and the fix routes each of the API’s messages to its field by the property name the message starts with.

Planning found four things the spec had wrong before any of this ran. The one I liked: the spec listed “marking a session complete after filing” as out of scope, on the grounds that nothing modelled it. The filing service had been doing it since milestone two. I had written a non-goal for a feature that already existed, and the browser test now asserts it.

What I am keeping

A green suite describes the surfaces the suite touches. The four bugs lived on four surfaces: a page that re-renders in a real browser, a build from an empty disk, a Docker context, and a second Docker context. Each surface joined the gate only after it had already failed once.

Two answers to one question, a hundred milliseconds apart. Unit tests cannot produce that. A browser test against the real server can, and it is worth the four minutes.

Reproduce the artefact, not the code. The reviewer that found the web image did not read harder. It built the thing the deploy would build. When the second image failed a day later, the deploy did the same job the reviewer had, only later and in front of me.

A non-goal is a claim about the code. Check it against the code before writing it down.

The cost of a guard is small next to the cost of finding out you had none. docker compose build in CI costs four minutes a push. Not having it cost a failed deploy and a morning.

The dashboard now has a client, and a second Google account seeded as a teacher so the next phone check can file and confirm as one rather than watching through view-as. Next is the half of “full-stack” nobody practises: backups with a restore drill, error tracking, secrets that are not plain environment variables, and the one-time rotation of the auth secret before anyone real signs in.