Part 4 · 4 September 2026
Seven bugs that lived between the tasks
Seventeen tasks, seventeen approving reviews, and a milestone that files attendance and pays teachers over HTTP. The last review found seven defects, and not one of them was inside a task.
series · Rebuilding a school-ops dashboard
The dashboard I’ve been rebuilding can now pay a teacher. Today a test filed five kinds of attendance over HTTP as a demo teacher, read the month back, confirmed it, finalised it as the manager, then did the thing I’d been waiting to see: it changed every pay rate in the database to ¥1 and read the month again. The numbers did not move. A finalised month is a snapshot now, and nothing that happens afterwards can reach it.
That was the milestone: the money path over HTTP. Six routes, thirteen tables, a ledger the database refuses to let anyone edit, and a React screen typed against a contract the API generates about itself. Between the last post and this one the pay calculations themselves got ported and checked against the old system to the yen, which is a story of its own I haven’t written yet. This post is about what happened when I put that calculation behind a web server.
I want to write about the reviews, because the pattern surprised me. Seventeen tasks went through the build, each one implemented by a fresh AI agent and reviewed by another before the next started. Every task passed. Then one more agent read the whole branch end to end and found seven defects worth fixing before merge. Not one of them lived inside a task. Every one lived in the seam between two.
A month that could bill zero, depending on a setting
The first one is the kind I now look for first.
To compute a teacher’s month, the service asks the database for every attendance row dated in that month. The agent wrote the query like this:
WHERE date::text LIKE '2026-09-%'
Turn the date into text, check whether it starts with the year and month. It works. Every test passed, on my laptop and in CI, because both run Postgres with its default date format, which prints 2026-09-02.
Postgres has a setting called DateStyle that controls how a date prints when you turn it into text. Under the default it prints year first. Under three other styles it prints 09/02/2026 or 02.09.2026. Change that one setting on the server and the pattern matches nothing. No error, no warning. The teacher’s month comes back empty and the invoice says ¥0.
I have a written rule for this project, inherited from the old system: a value with nowhere to go is an error, never a silent drop. This query obeyed the letter of it. Nothing was dropped. Nothing matched, which is worse, because a query with nowhere to match doesn’t raise anything at all.
The fix asks the date column for a range instead of asking its text for a shape:
WHERE date >= '2026-09-01' AND date <= '2026-09-30'
No conversion, no dependence on how anything prints. The reviewer found it by asking one question of the cast: what does this print as, and which setting decides.
A gate that agreed with itself
The API describes itself. On every build it writes out an OpenAPI document (a machine-readable list of every route, what it accepts, and what it returns), a second package turns that into TypeScript types, and the web app calls the API through those types. If I change a route and forget to regenerate the document, CI fails. That’s the drift gate, and I was proud of it.
Two of the six routes are “confirm my month” and “finalise the month”. Both are POST requests. The web framework answers a POST with status 201 by default, the code for “created”. The agent decorated both handlers as returning 200, the code for “fine”. The tests asserted 201, because that is what came back. The document said 200, because that is what the decorator said.
The drift gate was green the whole time. It compares the document the code emits against the document in the repository, and they agreed. They were both wrong in the same way. A gate that checks two copies against each other proves they match. It says nothing about whether either copy is true.
I’d been treating “the contract is checked in CI” as “the contract is correct”. Those are different claims, and the gap between them is exactly the width of a decorator someone typed from memory.
The form that lost ¥5,000
Filing attendance takes one request body with a type field: group lesson, private lesson, hourly work, project, expense. Each type cares about different fields. A private lesson has a student and a duration. A project has an event name and a payment amount.
The plan called for one flat request shape with every field optional, and a rule that rejects any field the shape doesn’t know. That rule worked. Send a field no type uses and you get a 400. Send a project’s payment_jpy on an hourly filing, and the server accepted it, filed the hourly row, and threw the ¥5,000 away. The field existed on the shape. It just meant nothing for that type, and nothing checked.
That’s the same rule again, broken from the other side. The value had somewhere to go on paper and nowhere to go in practice. The fix is fifteen lines: a list of which fields each type owns, and a refusal for anything outside it.
A spec that disagreed with itself three lines apart
The design document says a finalised month is read only from the snapshot, everywhere. Three lines later it defines the manager’s draft view as a live calculation. Both sentences are in the design document I approved. Nobody noticed, because the agent that built the teacher’s view read the first sentence and the agent that built the manager’s view read the second, and each did exactly what its task said.
So after finalising September, a manager opening the draft saw September recomputed from current rates, labelled final, and disagreeing with the invoices actually issued. The teacher route read the snapshot. The manager route didn’t. Each was correct against its own task.
I ruled that the snapshot wins, and the draft now shows snapshot figures for anyone already finalised and live figures for everyone else. The fix took an hour. What stays with me is that a per-task review cannot see this kind of defect. The reviewer of task nine had no reason to open task eleven’s file, and the contradiction was in neither. It was in the document both were built from.
The other three, briefly
A guard against two teachers filing the same private lesson (which would burn the student’s credit twice) checked the database before opening a transaction, and nothing at the database level backed it up. Two requests arriving together would both pass. The fix is a partial unique index, which is Postgres for “at most one row where this condition holds”:
CREATE UNIQUE INDEX attendance_academy_key_idx
ON attendance (session_key) WHERE type = 'academy';
A stand-in teacher’s filing was supposed to record “covered for” followed by the covered person’s name. It recorded their database id, a 36-character string of hex and hyphens, because the service had the id in hand and no lookup for the name. Harmless today, since nothing displays that field yet. Wrong on the day something does.
And the handoff document, the file the next session reads first, described a fix that had been discussed in a review and never made. The code still had the old line. A reviewer reading the doc against the code caught it. I’d have believed the doc.
Why the seams
I count it up like this. Seventeen tasks, seventeen task-scoped reviews, and their defects were caught: an agent that reached past the layering rule on the first service, a timestamp function with no test, a contract field that came out as the wrong type. Those reviews earned their keep. But each reviewer sees one task’s diff and one task’s brief. The seven from the end all have the same shape: one task’s decision meeting another task’s assumption, or a document meeting code that no longer matches it.
task 9 ──── reads spec line A ────┐
├── contradiction lives here,
task 11 ─── reads spec line B ────┘ in neither task's diff
task 12 ─── picks a flat DTO ─────┐
├── the invariant it breaks is
constraints ─ "nothing silently dropped" in a different file
On the last three milestones I’ve run this way, the whole-branch review has found something every task review missed. I no longer expect it to come back clean. I expect it to find the seams, and I budget for one fix wave after it.
Two things that went right
The tests mock nothing now. In the second post I wrote that our test setup mocked the auth library because it couldn’t load under the test runner, and that both sign-in bugs sat invisible behind passing checks. That gap closed this milestone. The API tests moved to a runner that can load the library, the four mock files went in the bin, and the tests now sign a real session cookie the same way the auth library does and send it. The guard that runs in production is the guard that runs in the test.
And an agent hit my usage limit halfway through a task, after committing its work and before writing its report. When I came back, the ledger file and the git log said exactly where it had stopped. One message resumed it. I’d written the rule about keeping a ledger after an earlier session re-ran three finished tasks from scratch. This was the first time the rule got tested, and it held.
The lessons I’m keeping
A gate that compares two copies proves they agree. Whether either copy is right needs a different check. I now ask, for every automated gate, what it would take for both sides to be wrong together.
A query with nowhere to match is the silent-drop rule’s quieter cousin. It raises nothing. Any query whose correctness depends on how the server prints something is a query waiting for a settings change.
Per-task review is blind to seams by design. Budget for a whole-branch pass and a fix wave after it. It has found something every time.
Read the handoff against the code, not on its own. A document that describes a fix is a claim. Twice this run the claim was ahead of the code.
“Fix it” beats “document the exception” on the first breach. The layering rule survived seventeen tasks because the first agent to break it got a repository, not a footnote. The second one would have pointed at the footnote.
The branch is pushed and the pull request is open. What’s left is mine: sign in from a browser and look at the screen, then put it on the mini-PC under my desk and file a month by hand. After that comes the part most portfolio projects skip, which is proving that a teacher cannot read another teacher’s pay, at the database, no matter what the application does.