Keeping control of the architecture when AI writes most (or all?) of the code
When I asked you a few weeks ago what you wanted to read about, ten out of fifteen replies asked the same question, and four of them used almost the same words: how do you delegate more to AI without losing control of the architecture?
One of you put it better than I could have. Comprehension is inverted now. You used to understand the code before it existed because you were about to write it. Now you build first and understand after, and that leaves gaps in how the code works and what shape the whole thing has.
I already wrote about giving up on reading every line. If you’re not reading the lines, control (or the illusion of it) has to come from somewhere else, so this post is about where mine moved to. The setting is Loonie Logs, the app I use for my own taxes, a React Native (Expo) app, a NestJS API, a small admin portal, AI receipt scanning, subscriptions, two app stores. Most of that code was written by Claude. None of it was merged by Claude.
That last sentence is the whole post. Everything below is a version of it.
Control lives at the boundaries, not in the lines
If you try to keep control by understanding every function body, you lose, there’s too much of it and it changes too fast. What you can keep is the shape: which repo owns what, what the contract between them is, where a change is allowed to land, what gets to run on your machine, and what “done” means.
So I put the review at the places where a change crosses a boundary, and I made the boundaries hard to cross without me.
1. I keep the commit and the deploy
Claude never runs git, npm, npx, eas, expo, docker or node in my projects. Not “it
asks first”. It doesn’t run them, at all. When it needs to run one of those, it hands me the exact
command and I run it.
This sounds like paranoia to some, about it removing a folder, and sure, a bit of that, but that’s
not the reason. The reason is that the commit is my review point. Every change the model makes has
to pass through my hands to become permanent, and the thing I review at that point is not the diff
line by line, it’s the file list. git status is an architecture diff. If I asked for a change in
the mileage screen and the list has a new file in src/shared/, a migration, and a change in the
export service, I don’t need to read the code to know I have questions.
Same with deploys. The API deploys when main moves, the mobile app builds when I push a tag. Both
of those are actions only I take, which means nothing reaches a user without me deciding it does.
A small thing that turned out to matter: last week it started writing branch names into the commands
it handed me, git checkout -b feat/... and so on. Nothing was executed, but reading them I had a
moment of “wait, did it touch git?”, so now the rule is that it doesn’t even write git commands. The
boundary has to be clean enough that I never have to wonder.
2. Two repos and a contract I can see
Loonie Logs is two separate git repositories, the API and the mobile app. They share Zod schemas,
TypeScript types and constants (CRA categories, provinces, tax rates), and there is no package, no
monorepo, no symlink. Both repos have a src/shared/ folder with the same files, and the rule is
that any change to a shared file is made in both repos in the same task.
I know. Every instinct says extract a package. I’ve kept it manual on purpose, because the shared folder is the contract between the two halves of the product, and I want every change to it to be loud. When the model changes a schema, I see two diffs in two repos, and the question “does the mobile app know about this?” answers itself. With a package it would be one diff, and the mobile side would break later, somewhere else, on a weekend.
The second rule that came out of this: before the API changes or removes anything, grep the mobile repo for it. The model is very good at pruning “unused” endpoints that are used by the other repo it isn’t looking at. That one rule has prevented more broken builds than any amount of code review.
And the one I learned the hard way, which I told in the last post: a service method with no route is not a feature. “Shipped” means I can trace the path from a button in the app, to the hook, to the endpoint, to the service. If any link is missing, it’s not shipped, no matter what the task list says.
3. Plans without code
Anything that takes more than a couple of steps starts in plan mode, and I have one requirement for the plan: short, high level, no code snippets.
I do that because I’m reviewing shape, not implementation. A plan that says “add a scanSource
column, thread it through the intake service, expose it on the quota endpoint, show it in the admin
list” tells me exactly where the change lands and whether I agree with that. A plan with forty lines
of TypeScript in it makes me read TypeScript, and the moment I’m reading TypeScript I’ve lost my
focus where it was supposed ot be needed.
The second half of the rule is the one that protects the architecture the most: if something breaks in the middle of a task, stop and re-plan. Don’t patch forward. Patching forward is how you end up with a workaround, then a workaround for the workaround, then a helper that exists only because of the first workaround, and three weeks later nobody, human or model, can tell you why that file exists. Every architecture I’ve seen rot, mine included, rotted one patch-forward at a time.
4. Rules are earned, not written up front
My CLAUDE.md has a section called “mobile hard rules” and the heading says: each of these caused
a real failure or App Store rejection. That’s the standard for getting into the file. I didn’t sit
down and write a style guide, every rule in there is a scar.
A few of them:
expo-dev-clientgoes indevDependencies, neverdependencies. Independenciesit breaks production builds.- Permission buttons say “Continue” or “Next”. Apple rejected “Grant Permission”.
- The paywall has tappable Terms and Privacy links, and there’s in-app account deletion. Both are rejections if missing.
- The dev build and the production build can’t share a URL scheme. Last Saturday the share extension
kept opening the dev app instead of the TestFlight one, because both registered
loonielogs://, and it looked for an hour like the extension was broken.
Next to CLAUDE.md there’s a lessons.md with a stricter format: the date, what went wrong, and
the rule that came out of it. The rule at the top of that file says to read it before starting work,
“because these mistakes were made at least once already”. When I correct the model, the correction
goes in there, in that session, before we move on.
Now, the uncomfortable part, which I also said last time: the model is probabilistic, so everything in those files is an instruction, not a rule. It will follow them most of the time. Most of the time is not a guarantee.
So there’s a ladder. Something goes wrong once, it becomes a line in lessons.md. It goes wrong
again, it becomes a check the model has to run before calling the task done (“after any SDK
upgrade, test a real upload and an airplane-mode save on a device”, that one exists because the
Expo 57 upgrade swapped the global fetch and every upload broke while the unit tests, which mock
fetch, stayed green). It goes wrong a third time, it stops being words and becomes code: a unit
test, a type, a lint rule. The “net income” card I talked about in the last post ended up as a test
that fails if the dashboard contains the words profit or net. The receipt-scanning schema has a test
that fails if someone adds additional union-typed fields, because Claude’s structured output
rejected the schema at sixteen and we found out in production.
Instructions for the things that are cheap to get wrong, code for the things that aren’t.
5. Watch the reasons, not just the code
This is the one I didn’t expect.
In July I removed the user-facing web app from Loonie Logs. Users are mobile-only now, the web is the marketing page, legal pages and an admin portal. At the time my reasoning was “nobody’s using the web app”. I hadn’t measured that. It was a feeling, and it was probably right, but it was an assumption.
Last week I was weighing whether to bring part of it back, and I asked Claude for a verdict. It argued against it, and one of its arguments was that the web app had been removed because nobody used it. My own unverified assumption, from three months earlier, handed back to me as evidence. It wasn’t lying, that’s what the project notes said. But if I hadn’t remembered where that sentence came from, I’d have made a second architecture decision on top of a first one that was never checked.
The rule that came out of it is in lessons.md now: when a recommendation leans on a past decision,
say where the rationale came from, “you assumed X in July”, and say whether it was ever verified.
Don’t present my old assumption back to me as data.
Keeping control of the architecture turned out to include keeping control of why the architecture is the way it is. The model is a very good mirror, and mirrors are convincing.
A smaller version of the same thing: scope. I asked for email verification. The per-user “mark as verified” button in the admin portal was enough for me, I said so, and the model also built a bulk script to mark everyone. Harmless, right? Except every one of those is a file that exists for a reason nobody will remember. The rule now is: smallest version first, bulk tooling only when I ask. Architecture doesn’t only erode through bad design, it erodes through extra stuff that was offered and never refused.
6. One task at a time, verified on a device
I work on this on weekends, around a full-time job, family, and taekwondo. That means I come back to the project cold, every time, and the first thing I do is ask “where are we, what’s next?”. The task list answers that, and the task list is only as good as its checkmarks are true.
So one task at a time, and a task is done when I’ve run the app, on a phone, and seen it do the thing. Not when the tests pass, not when the model says it’s done. My task files literally have two states for a finished item, “pending device verification” and “device-verified”, and the model isn’t allowed to flip the second one.
This is the slowest part of the whole setup. It’s also the one I’d keep if I had to drop everything else.
What this costs, and what it doesn’t fix
It costs speed. Letting the model run git and npm, merge its own work and keep going would be
faster, for a while. Every boundary I described is a place where the work stops and waits for me,
and I’m the bottleneck by design.
It also doesn’t fix the model. It still repeats mistakes, it still reaches for the old import, it still occasionally marks something shipped that nobody can reach. The rules reduce the rate, the tests and the device check catch what gets through. Nothing in here makes the output trustworthy, it makes the output reviewable at the altitude I can afford.
And that’s the honest version of the inverted-comprehension problem. I understand the shape of Loonie Logs completely: what talks to what, where the contract is, where a change is allowed to land, what runs on my machine and what runs on a user’s. I understand the function bodies partially. I’ve decided that’s the trade I can live with, and these boundaries are how I make sure it stays that trade and doesn’t quietly become “I understand neither”.