Part 10 · 30 September 2026
No problems this month
The last milestone before version one closed was a page that lists every row that will bill wrong. The database refused two of the eight checks I designed, because it already made those rows impossible. The final review found the page could say 'No problems' for a month the app refuses to close. Then fifteen nights of backups, and version one.
series · Rebuilding a school-ops dashboard
At the end of the last entry, I wrote that the next piece would be absences, cover, and who is in the room. I built a page instead because of a sentence in the handover.
The handover defines correct as “count exact months”. Its list of open items consists entirely of data faults: group lessons with no class, two classes sharing a Friday slot, private lessons filed with no duration. The domain already knew about some of them. It emits each warning as a line of text, and the draft page printed those lines under a heading. Nothing grouped them, nothing named the row, and nothing said where to fix it. A manager reading “No rate for a3f9…” has to know what that means and where to go.
So the milestone was one read route and one screen. A manager or head opens a month’s problems page and sees every row that will bill wrong, or bill on a guess, grouped by severity and by teacher. Each problem links to the screen where you fix it. The draft’s warnings block shrinks to one line with a count and a link. I left the domain package and finalise alone, so the page informs and decides nothing.
Two sources, one list
The problems come from two sources.
domain warnings (text) rows the domain read (SQL)
"PENDING approval: ..." group lesson, no session that day
"No rate for ..." hourly row, no duration
"No instructor rate ..."
"Matched ... by ignoring ..."
| |
+---------- classify ------------+
|
v
{ code, severity, teacher, row, message, fix }
|
blocks billing / to review
The domain’s warnings stay text inside the domain. The API matches each one by its prefix and turns it into a typed problem with a stable code. A warning that matches no prefix becomes OTHER, so a new warning added to the domain later can never vanish from the page. The two row checks run over the same set of rows the domain reads, with the same rule for rows moved into or out of the month.
The client renders by code, never by message text. I held that one rule hardest, because a screen that parses English sentences breaks the day someone rewords one.
The database said no, twice
I designed eight codes. The plan had tasks for all of them. The third task’s agent stopped before committing anything and asked a question. One of my checks was “a group lesson with no class”. Its end-to-end test needed to insert such a row, but the database refused. A check constraint from the third migration forbids a group row without a class, and a foreign key forbids a class that does not exist. The row my check looked for could not be stored.
I withdrew the code. The agent resumed, then stopped again with the same kind of question about a second code: “an academy lesson with no student”. Same migration, same reason. By then, the agent had read and listed all four check constraints on the table, so I knew nothing else on my list would collide.
Six codes remain, plus OTHER. The handover’s open items were real on the old system, a spreadsheet with a script, where any cell could hold anything. The new schema made two of them impossible months ago, and I had forgotten, because I wrote the check list from the handover rather than from the schema. Part six of this series was called “The database says no”. It was about a different refusal, and I still did not see this one coming from my own schema.
The Critical
Five tasks, one agent to build each and one to review each, then a whole-branch review on the strongest model I have. The per-task reviews found nothing important. The whole-branch review returned one Critical and two Important.
The Critical was in the code specified by my plan. The pending check only looked at expense and project rows. That matched my mental model: expenses wait for a manager; lessons do not. But the rule that marks a row pending does not look at its type. It looks at whether the row carries an expense. A group lesson with a taxi fare attached is pending. Finalise refuses to close a month with any pending row. So a manager could open the problems page, read “No problems this month”, press finalise, and be refused. The page’s one job is to tell you what will stop you, and it would have hidden the very thing that did.
The per-task reviewer could not see it. That agent checked the rules task against my plan, and my plan agreed with itself. You need the approval code from an earlier milestone and the new rules in one view to see the gap, and the whole-branch reviewer had both. The fix removed one condition. The test gained a pending group row with a ¥900 transport fare.
The two Important findings were the same kind of seam. The page checked a group lesson moved into September from late August against September’s sessions, so the lesson looked like it had no session. The session read now spans the dates of the rows it checks, not the calendar month. And rows filed by a person on the no-bill list raised problems, although the domain skips those rows. Each fix took a few lines, and no test that knew only one side could have caught either.
Deployed, and nothing to report
The deploy was the quietest of the project. No migration, one route, one screen. I brought the tunnel up, opened September’s problems page, and read “No problems this month”. October said the same. That is the correct answer because the seed data is clean and I approved September’s two pending expenses in the last milestone. It also convinces nobody, me included. The proof that the page finds problems lives in the end-to-end test, which plants one of each and asserts them all by code, severity, teacher, and fix, in order.
Version one
The last exit criterion of the ops milestone was a date. The backup job runs at three every morning and writes an encrypted dump to object storage, and a retention rule prunes anything older than fourteen days. The criterion was to see fifteen consecutive nights in the bucket, which could not happen before the twenty-sixth.
I checked on the thirtieth, four days late. Fifteen dates, from the sixteenth to the thirtieth, with two files for each, all written at 03:00. The marker file the job writes last said the thirtieth. One extra object from the eleventh is still there, and the listing surprised me until I read the pruning script: it keeps the first backup of each month for six months, and the eleventh was September’s first.
Every dump since the twentieth is 72,328 bytes. Nothing changed in the database for ten days, because nobody uses the app. Version one has a real deploy pipeline, encrypted nightly backups with a monthly restore drill, error tracking with traces, a runbook, approvals with an audit trail, and a page that lists what will bill wrong. It has no domain. It runs on a mini-PC under my desk and reaches the internet through a Cloudflare quick tunnel with a random address, which I bring up by hand and take down after. The page title says “Minim Money Path” and there is no branding.
My agent made one mistake today. When asked what was left, it listed “rotate the auth secret before version one”. That rotation ran on the tenth, as the ops milestone’s second exit criterion. A memory note from before the milestone still said it was pending, and the agent trusted the note over the runbook. The runbook was right.
What I am keeping
Write checks from the schema, not from the old system’s bug list. Two of my eight codes looked for rows the database already refuses to store.
A page that says “no problems” must agree with the thing that refuses. The problems page and finalise read the same rows. They must use the same rule, and the test for that is a row that trips one and not the other.
The seam bugs are found by the reviewer who holds both sides. Three of three findings in the whole-branch review sat between this milestone’s code and an earlier one’s. Per-task review cannot see them, by construction.
A notes file goes stale faster than a runbook. The runbook records what ran. A note records what someone meant to do. When they disagree, believe the record.
Version one is an operations milestone, not a product one. It proves the app can be deployed, backed up, restored and watched. It does not prove anyone wants it.
Next is the move from my desk to something a real school could use: a domain, a named tunnel, and a name on the page. Absences and cover are still waiting.