By Yves Habchy · September 2026
In the first week of a renovation job I found a function whose name said it cleaned up duplicate citizenship records. I could read it end to end. It grouped records by student and day, compared the first tag on each, and deleted the rest, which means it could delete records that weren't duplicates at all.
The problem is, citizenship wasn't defined anywhere. Not in the schema, not in a comment, not in the client's own description of the product. So it had to be something the business meant, held somewhere other than a table.
What I had was a category of records, a scoring path that read them, and a dedicated tool for cleaning up their duplicates. The rows gave me the rest: tags like warning, asked to leave, character points. Teacher-logged behaviour notes, worth a few points either way in a student's daily score. I settled for that by inference, because nothing in the codebase told me what the word meant. Then I shipped changes to the code that reads those records without ever being sure what they represented. I still can't tell you why it's called citizenship.
The app was built in Lovable over about six months, then handed to us to make production ready in five weeks against a fixed pilot date. It's an education platform: it scores students daily on effort rather than test results, and reports that score to their parents as a letter grade. Supabase for the backend and auth, React on the front end. This was my first time working on a fully vibe-coded app, so my expectations were vague. Exposed API keys, missing auth, the kind of thing you read about on X and Reddit.
I found all of that, and fixing it was the easy part. What cost me was code I could read perfectly and still not say why it existed.
The first job was getting it to run locally. I was invited to the Lovable project and exported the code. I did a quick overview of the backend and found 132 migration files. So I create a fresh Supabase project, point the app at it, run the migrations, and I have the app.
It wasn't that smooth. The second migration failed, calling a function that didn't exist because the extension providing it wasn't installed. I installed the extension into the schema the later migrations expected. Migration thirty-five then failed, because some of the earlier migrations call that same function with a schema prefix and some without, and no single install location satisfies both. The fix was moving the extension and widening the database search path. Two lines, most of an afternoon.
Another migration dropped an access policy from a table no migration creates. The table isn't in the export. It isn't in production either. I marked that migration applied without running it. Another created a trigger an earlier migration had already created, and Postgres has no if-not-exists for triggers, so the second one just fails.
Two days of this and the schema was up. Then the app couldn't read it. Sixty-six of seventy tables had no permissions for any role the application uses; every request came back permission denied. Those grants exist in the original project. They were applied silently at setup, six months earlier, and nothing recorded that it had happened.
With the grants fixed I could finally use the app.
The second job was the audit. I don't read a codebase this size by hand, so I told the agent to analyse it and run one. No formal prompt, just a casual command, because I wanted to see what it would yield.
The first pass came back with 108 findings, 23 critical. They were what you'd expect. A setup endpoint that creates a platform administrator and returns the password, sitting before any authentication check and before the rate limiter. A report endpoint with no authentication at all that will hand you any child's daily scores and their parent-facing summary if you know their ID. A function that deletes an entire school, gated on nothing more than the presence of an authorization header, which the anonymous key in the browser bundle satisfies.
Then we ran the same codebase through something heavier. An orchestrator forbidden from reviewing anything itself, splitting the code into the smallest reviewable units it can find. Every unit goes to two different frontier models in parallel, blind to each other. Anything only one of them reports, or that they rate differently, goes to a second round where each is shown the other's finding and asked to concur, revise, or refute with evidence from the code. Anything still disputed after a third round goes back to the orchestrator, which reads the cited lines and decides. Three passes over everything: correctness, security, production readiness.
It returned 319 findings. Eighteen of the criticals were ones the first pass had missed, including a school administrator being able to promote themselves to platform superadmin, because a policy checked who you were and never checked what you were allowed to become. That register replaced mine as the one we worked from.
Both passes still missed things. Testing manually, I found that any logged-in user can create a school. A student can create one and make themselves its administrator.
All of that was findable. What follows is the part where reading harder didn't help.
An attendance function that sends a teacher's typed note to a language model to work out who was absent. Fully built, permission-checked, wired to a paid AI account, and called from nowhere in the app. I couldn't tell whether it had been retired or never wired up, so I hardened it during the security pass like a live function.
A screen where teachers enter the day's data one row at a time, with a comment saying a bulk control was removed because it caused errors. The bug isn't written down anywhere else, so I can't tell what it was or whether it still exists. Somebody documented that decision, and it still wasn't enough to act on.
The scoring is the one I couldn't fix.
The product gives every student a score each day and shows it to their parent as a letter grade. That number is the product. It's what the schools are paying for.
Four things compute it. A daily calculator and a weekly one, which derive points from goals completed. A general scorer and a batch scorer, which compute something else entirely: a blend of effort, mastery and reliability over rolling two-week and one-month windows. All four write to the same field on the same row. Whichever runs last wins.
They don't agree. The general scorer and the batch scorer weight the same sub-score sixty-forty and forty-thirty-thirty, so the same student gets a different number depending on which one ran. The letter grade boundaries are written out in five separate places. The daily calculator stamps its output with one version label and the other two stamp another, so somebody knew, once, that these were different things.
None of that is hard to see. It's all sitting in the files. What's missing is any record of which one is authoritative. Nothing in the code, the comments or the client's description says.
So I can't pick one. Picking one changes what grade a child gets and what their parent is told about their week. I can't merge them for the same reason, and I can't test my way out, because the test would encode whichever answer I guessed.
I escalated it as a product question and it left my hands.
Six months of building produced an application that runs. That part is real. But it runs with no record of why any of it is the way it is, and the moment someone else has to change it, that's the bill coming due.
I'd expect the tool to keep that record. Not the client. The whole point of these tools is that non-technical people don't need to know what a developer knows, and knowing that the reason behind a decision is worth keeping is exactly the kind of thing a developer knows.
So the code was readable. The reasoning was gone. And the person who could have told me was never told it mattered.