The AI Wrote It. You Still Own It.
Generating code got cheap. Understanding it did not. Review is now the bottleneck skill, and reviewing generated code is harder than reviewing a colleague's.

The diff was forty lines and it looked right. A pricing summary for a customer dashboard: fetch the line items, group them, compute an average, return a formatted total. Clean names, early returns, a comment where a comment belonged. I read it twice, approved it, and moved on to something that felt like it needed more attention.
It shipped on a Thursday. On Friday a support ticket said a customer's dashboard showed NaN where a number should be. The customer was new; they had no line items yet. The average divided by an array length of zero, and the formatter — also generated, also plausible — passed NaN straight to the UI without complaint.
Nothing about that diff looked wrong. That is the part I keep returning to.
The cheap part got cheaper
The story going around is that AI is automating software engineering. It rests on a premise anyone who has done the job for a few years knows is false: that the expensive part was typing the code.
It never was. The expensive part was deciding what to build, understanding a system well enough to change it without breaking three other things, and knowing whether what you built is correct. Typing was the short tail at the end. On a good day I spent an hour writing code and five hours figuring out which hour to write.
What actually happened is narrower and still enormous: the cheap part became nearly free. I produce a first draft of almost anything in seconds, and I use that every day — for the boilerplate I resented, the unfamiliar API I would have spent forty minutes on, the migration script I would have put off until Friday.
But none of it made the expensive part cheaper. Because generation got so much faster, verification is now the narrow point in the pipe, and everything queues behind it.
Reviewing a machine is not like reviewing a person
I have reviewed a lot of human code and assumed the skill would transfer. It transfers less than I expected.
When a colleague writes something odd, the oddity carries information. A strange retry loop, a redundant-looking null check, a comparison that seems backwards — these usually mean something. Maybe they hit a bug in that library last quarter. Maybe that endpoint really does return null on Tuesdays. The oddity signals a reason, and there is a person I can ask. Half my best review questions have been some version of "why is this here?"
Generated code has no such signal, because it has no intent. An odd line may encode a real edge case learned from a thousand codebases, or it may be there because it statistically follows the previous line. There is nobody to ask. Ask the model and it produces a confident, articulate, entirely post-hoc explanation — a rationalisation, not a memory.
The surface is also uniform. A junior's code looks like a junior wrote it: the naming wobbles, the structure sags, your eye slows down in the right places. Generated code is stylistically excellent everywhere. The cues my review instinct was trained on have been detached from correctness. Everything reads like a competent person wrote it, including the parts that are wrong.
Plausibility and correctness have come apart, and my intuition was calibrated on a world where they travelled together.
The shape of the failure
The failures have a characteristic shape. They are almost never wrong in the middle. They are correct on the happy path and silently wrong at the edges.
// Generated. Correct for every input I had in mind.
function averageOrderValue(orders: Order[]): number {
const total = orders.reduce((sum, o) => sum + o.amount, 0);
return total / orders.length;
}For a customer with orders this is exactly right. For a new customer it returns NaN — the worst possible failure value, because it does not throw. It flows downstream, formats into a string, and surfaces three systems away from where it was born.
The same shape is everywhere once you look. Timezones:
// Generated. Correct in UTC. Wrong for half the world.
function isToday(date: Date): boolean {
return date.toISOString().slice(0, 10)
=== new Date().toISOString().slice(0, 10);
}A perfectly reasonable line that quietly means "is this today in UTC." For a user in Tashkent it is wrong for five hours of every day, and it passes every test written on a machine running UTC.
Concurrency has the same character:
// Generated. Correct with one caller. A race with two.
async function incrementUsage(userId: string) {
const row = await db.usage.find(userId);
await db.usage.update(userId, { count: row.count + 1 });
}Read-modify-write, no transaction, no atomic increment. It works in every manual test and in CI. Under load it loses counts, showing up as a billing discrepancy nobody can reproduce.
None of these are stupid, and that is what makes them dangerous. Each is the code a competent, slightly hurried engineer writes while thinking about the main case — a fair description of what the model is doing. It has no production incident in its past that made timezones feel personal.
Verification moved from reading to running
The practical consequence is that I trust my eyes less and my tooling more.
Reading used to be a decent verification method. When code came from a person whose habits I knew, reading told me a lot. Now it tells me the code is well-formed, which was never the question. So the things that execute have appreciated: types that make the bad state unrepresentable, tests that pin the contract, and the unglamorous act of running the thing with weird input.
averageOrderValue(orders: Order[]): number promises a number and delivers NaN. Had the signature been number | null, the failure would have been a compile error instead of a support ticket. Constraints that used to feel like ceremony now pay for themselves — they are the part of the review that does not get tired at 6pm.
Tests earn more too, but only if you keep hold of the right end of them. Ask the model for tests and it writes tests that pass — often by encoding the same assumptions that produced the bug. It generated orders.length as a divisor; it will generate a suite where orders is never empty. Test and code are wrong in the same direction, which is worse than no test, because now you have a green check mark.
So I keep one line: when a test defines the contract, I write it. Not all tests — most are volume, and volume is what a model is good at. But the handful that say what a function promises when the array is empty and the clock is in Tashkent are the specification, and handing the specification to the thing you are verifying is circular.
Specifying precisely is the skill that appreciates
The most reliable predictor of whether I get useful code is how precisely I asked.
A vague prompt gets a plausible answer to a question you did not ask. "Add caching to this endpoint" gets caching — with an invented TTL, an invented key scheme, and no invalidation, because none of those were specified so all were guessed. The guesses are invisible in the diff; they appear as ordinary lines.
"Cache this per user for 60 seconds, key on userId plus plan tier, invalidate on subscription change, do not cache errors" gets something I can evaluate, because now the diff either matches what I said or it does not. The specification is what makes review possible at all.
This is the same skill that makes someone good at delegating to a junior, running faster and without pushback. The "without pushback" is the dangerous half. A junior asks "wait, what should happen if they have no orders?" That question is a gift — the review happening before the code exists. The model does not ask. It picks something reasonable-looking and moves on, and the decision you never made now sits in your codebase looking like one you made.
What I would hold lightly
I am not sure about the atrophy argument, and I would rather be honest about that than resolve it neatly.
The worry is straightforward: if I never write the loop, I lose the feel for the loop, and the feel is what tells me the generated version is off. There is something to this. I am slower at things I have delegated for a year.
But the counterargument is strong and cannot be waved away. We already gave up writing assembly. Almost no working engineer can hand-write the machine code their program becomes, and the industry did not collapse; it built things that were impossible by hand. Then manual memory management, manual DOM manipulation, manual server provisioning. Every transition came with someone insisting the craft was dying, and every time the craft moved up a level and got more ambitious. Betting against that pattern looks foolish.
Here is what I cannot make fit it. Every previous abstraction was deterministic. The compiler produces the same output for the same input; when it is wrong it is wrong reproducibly, and the bug gets filed and fixed and stays fixed for everyone. I stopped reading assembly because I could trust the compiler categorically — not "usually," but as a property of the system.
This abstraction is not like that. It is right most of the time in a way that is not guaranteed, not reproducible, and not uniform across the input space. Trusting a compiler is a decision you make once. Trusting a model is a decision you make per diff, forever — and the skill required to make it well is the skill the abstraction erodes.
I do not know how that resolves. Maybe verification tooling gets good enough that the guarantee comes from somewhere else and the analogy holds after all. Maybe a generation grows up reviewing rather than writing and develops instincts I cannot picture from here. Both seem possible. I would not bet heavily on either.
Your name is on the commit
Whatever the answer, one thing the tooling does not change.
"The AI wrote it" is not a defence in a postmortem, for the same reason "I copied it from Stack Overflow" never was. You put it in the diff, you approved it, it went out under your name. Where the characters came from was never what anyone was holding you responsible for.
The rule I hold to: if I cannot explain a line, it does not go in. Not "I could probably figure it out." Explain it — what it does, why it is there, what happens when the input is empty. If I cannot, deleting it is usually the right option.
The habits that make this workable are unglamorous:
Review the diff, not the chat. The conversation is persuasive; the diff is the truth. Reading the model's explanation first primes you into agreeing with it. Read the code cold, the way you would read a diff from a stranger.
Ask for the smallest possible change. A 400-line diff will not get reviewed properly — not by me, not by anyone — and the model is happy to produce one.
Make it explain the failure modes before you accept. "What breaks if this array is empty, if two requests arrive at once, if the user is in a different timezone, if this call times out halfway?" It is often genuinely good at this, better than my tired eyes at 6pm. It just does not volunteer it. The asking is the review.
Keep the contract-defining tests human-written. Everything else can be generated.
None of this is new wisdom. Review carefully, define your contracts, know what your code does. What is new is that the discipline used to be partly enforced by friction — writing four hundred lines by hand meant you had thought about four hundred lines. That enforcement is gone. The friction was doing work nobody noticed it was doing, and now nothing does it unless you do.
The engineers I see doing well with these tools are not the ones typing fastest. They are the ones who were already precise about what they wanted and already sceptical of code that looked fine. The tools did not create those habits. They just started charging for their absence.
Filed under


