Your team is shipping pull requests twice as fast as last year, the CI pipeline is green, and every diff looks cleanly formatted. Yet your production incident rate is quietly climbing.
According to Google Cloud's DORA State of AI-Assisted Software Development research, while roughly 90% of engineering teams now use AI tools daily to accelerate coding, increased AI adoption without matching quality guardrails correlates directly with a measurable drop in software delivery stability. Code generation is no longer the bottleneckβcode verification is.
When pull request volume doubles while human review capacity stays flat, engineering teams fall into the "LGTM trap": rubber-stamping large, plausible-looking diffs that harbor silent, state-corrupting bugs.
The Problem: Plausible Code Is Harder to Debug Than Broken Code
When a junior developer writes a bug from scratch, the code usually looks suspicious. Naming is inconsistent, edge cases are visibly missing, or the syntax feels clunky. A senior reviewer spots the smell in seconds.
AI-generated code flips this heuristic on its head. Modern coding assistants produce code that is:
- Syntactically idiomatic and cleanly formatted.
- Accompanied by confident, well-structured PR descriptions.
- Paired with unit tests that passβbecause the tests were generated from the exact same flawed implementation context.
The Common Mistake: Local Correctness vs. System Invariants
Coding agents excel at local context (the open file and immediate type definitions), but they frequently miss system-wide invariantsβsuch as idempotency, transactional boundaries, permission scoping, and partial failure recovery.
Consider a real-world backend scenario: updating a user's subscription tier and syncing billing credits.
What Gets Approved in a Hurry (The Silent Bug)
An engineer prompts an assistant to "Add an endpoint handler that upgrades a user's plan and grants bonus credits, with unit tests." The PR arrives looking clean:
// β Looks clean in a 600-line PR diff, but breaks production invariants
export async function upgradeSubscription(
userId: string,
newPlanId: string
): Promise<{ status: string; credits: number }> {
const user = await db.users.findUnique({ where: { id: userId } });
if (!user) {
throw new Error("User not found");
}
// 1. External call inside business flow without idempotency key
await billingClient.chargeUpgradeFee(user.stripeCustomerId, newPlanId);
// 2. Non-atomic read-modify-write (race condition under concurrent requests)
const updatedCredits = user.credits + 500;
// 3. Separate DB writes without a transaction
await db.subscriptions.update({
where: { userId },
data: { planId: newPlanId, updatedAt: new Date() },
});
await db.users.update({
where: { id: userId },
data: { credits: updatedCredits },
});
return { status: "upgraded", credits: updatedCredits };
}
Why did this pass review?
- The TypeScript types compile cleanly.
- The auto-generated unit test mocked
billingClientanddb, asserting thatcreditsequaleduser.credits + 500. - It was buried inside a 14-file PR alongside boilerplate route definitions and DTOs.
The Consequence in Production
Two weeks later, a client network timeout triggers an automatic HTTP retry:
-
Double charging:
billingClient.chargeUpgradeFeeruns twice because no idempotency key was passed. -
Lost updates / Race condition: Concurrent requests read the same
user.creditssnapshot in memory before writing. -
Partial state corruption: If
db.users.updatefails due to a transient lock timeout, the user is billed and their plan is updated, but their credits vanish.
The Improved Approach: Designing for Verification, Not Just Generation
Fixing the code requires enforcing transactional boundaries, atomic increments, and idempotencyβthings a reviewer only catches when diffs are small and architectural constraints are explicit:
// β
Production-safe: Idempotent, atomic, and transactionally bounded
export async function upgradeSubscription(
userId: string,
newPlanId: string,
idempotencyKey: string
): Promise<{ status: string; credits: number }> {
return await db.$transaction(async (tx) => {
// 1. Prevent duplicate execution via idempotency record lock
const existingOp = await tx.idempotencyKeys.findUnique({
where: { key: idempotencyKey },
});
if (existingOp) {
return existingOp.responsePayload as { status: string; credits: number };
}
const user = await tx.users.findUniqueOrThrow({ where: { id: userId } });
// 2. Pass deterministic idempotency key to external provider
await billingClient.chargeUpgradeFee(user.stripeCustomerId, newPlanId, {
idempotencyKey,
});
await tx.subscriptions.update({
where: { userId },
data: { planId: newPlanId, updatedAt: new Date() },
});
// 3. Atomic database increment instead of in-memory calculation
const updatedUser = await tx.users.update({
where: { id: userId },
data: { credits: { increment: 500 } },
});
const result = { status: "upgraded", credits: updatedUser.credits };
await tx.idempotencyKeys.create({
data: { key: idempotencyKey, responsePayload: result },
});
return result;
});
}
If the implementation forgets to wrap two database calls in a transaction, the generated test simply mocks both calls and asserts each was called once. To break this loop:Click to view the hidden test smell that masks AI bugs
When an AI assistant writes both the implementation and the test suite in the same prompt, it generates mirror testsβtests that assert what the code does rather than what the business contract requires.
Shifting the Workflow: How High-Performing Teams Adapt
As highlighted in Google's engineering practices on The Standard of Code Review, the primary purpose of code review is ensuring that the overall code health of the system improves over time. When code generation becomes virtually free, your review standards must tighten, not loosen.
Use this checklist before opening or approving any AI-assisted pull request:
- [ ] Cap PR size at 250 lines of logic: If AI helped you write 800 lines in 20 minutes, split it into three stacked PRs so a human can actually reason about the state transitions.
- [ ] Separate boilerplate from business logic: Never mix auto-generated migrations, DTOs, or formatting changes in the same PR as core domain logic.
- [ ] Audit the tests before the implementation: Check whether the tests verify edge cases and failure rollbacks, or if they merely assert that mocked functions were called.
- [ ] Check system boundaries first: Look directly at external I/O, database transactions, concurrency locks, and authorization checksβwhere local context models fail most often.
- [ ] Require the author to explain the failure mode: Every PR description should answer one human-written question: "What happens if this code fails halfway through execution?"
Explore more production open-source projects on my GitHub
The Takeaway
AI coding assistants don't create bad engineering culturesβthey amplify your existing bottlenecks. If your team treats pull request velocity as the primary metric of productivity, AI will help you accumulate technical debt and silent production bugs at record speed. Keep batch sizes small, separate test design from implementation generation, and treat human code review as an architectural safeguard rather than a syntax check.
What is the most subtle bug or production failure mode that slipped through an AI-assisted PR in your team recently, and what guardrail did you add to catch it next time?
Sources & Further Reading
- Announcing the 2025 DORA Report: State of AI-Assisted Software Development β Google Cloud Blog
- The Standard of Code Review β Google Engineering Practices Documentation
- Your Team Ships 2x More Pull Requests Since Adopting AI (GitClear Research Analysis) β AlterSquare
Connect with Me
If you found this article helpful, let's connect!
- π» GitHub: johnnylemonny
Top comments (18)
Great article, john! πΊ
I also feel that AI is increasing not only coding speed, but also understanding debt at a pretty crazy pace.
AI can generate code that looks reasonable and behaves reasonably on the surface, but sometimes it contains unintended bugs, or it is basically just connecting the visible pieces without really understanding the whole system πΉ
Of course, humans can do the same thing. But when the implementation speed becomes much faster, it also becomes much easier to miss those problems.
We can automate more of the review process with AI too, and I think that definitely helps. But in the end, if humans are still responsible for what gets shipped, we still need to understand the code well enough to take responsibility for it.
So for me, one of the biggest challenges of AI-assisted development is not only how much we can automate, but whether humans can keep enough understanding of the system while everything gets faster πΈ
"Understanding debt" is such a spot-on term for this, @nyaomaru! πΊ
You nailed the core tension: even if we use AI to automate parts of the review or catch surface-level issues, you can't outsource accountability. When a production incident happens at 2 AM, "the agent connected the visible pieces" isn't a fix. A human still needs a solid mental model of the system to debug the state transitions.
It actually reminds me of our work on
changelog-bot, even when automating changelog generation, extracting the real βWhyβ behind a PR requires strict constraints so the model doesn't just summarize the surface diff without context. Keeping PRs small and intentional really feels like the only way to keep our own mental models in sync with the codebase as velocity goes up! πThe Reviewerβs Paradox is so true. Messy code makes you slow down, but clean AI code makes you trust it too fast π The upgradeSubscription example shows it perfectly, three real bugs hiding in code that looks totally fine. βWhat happens if this fails halfway?β is going into my PR template today!
Awesome to hear it's making it into your PR template! π
That false sense of security is the sneakiest part - syntactic polish tells you nothing about state safety or system invariants. Forcing authors and reviewers to answer that single failure-mode question breaks the spell and grounds the review in reality. Hope it catches a few silent bugs before they reach production!
The βproduction-safeβ version still seems to have a boundary worth calling out: if
chargeUpgradeFeesucceeds and a later DB write fails,$transactioncannot roll back the external charge. Reusing the provider key helps a retry, but the code still needs a recovery path for a charged customer whose local upgrade never committed.Also, the initial
findUniqueis a lookup, not a lock on a missing idempotency row. The example needs to spell out the unique constraint/concurrency handling rather than calling that read a record lock. A test that forces failure immediately after the charge would make a good counterexample to the βgreen tests = safeβ problem you describe.Spot-on critique, Ziwei. You pointed out the exact boundary where single-database abstractions break down in distributed systems:
db.$transactioncannot be rolled back by the database. If the local commit fails after Stripe succeeds, you end up with a charged customer and no upgrade until an outbox pattern, reconciliation worker, or automated refund/compensation flow steps in.findUniqueread isn't a lock. Without an atomic reservation (like an immediate insert relying on a unique constraint or an explicit row lock), two concurrent requests can both readnulland fire duplicate external charges before either writes the key.billingClient.chargeUpgradeFeeis the exact test every team should write here - it immediately exposes that mock-heavy unit tests are lying about rollback safety.Really sharp observations. Thanks for highlighting the nuance! π
The tests-from-the-same-context point is the one that bites hardest. A test written alongside the code shares its blind spot, whoever wrote it. The cheapest counter is a test that compares against something the change didn't produce: a sibling implementation, a recorded output, a spec example. Then a green run actually means something.
Spot on. If the expected values come from the same mental model that wrote the implementation, a green build just proves the code agrees with its own assumptions.
Anchoring tests to an external source of truth - whether that's a fixed spec fixture, a production recording, or differential testing against legacy output, is the simplest way to break that echo chamber and turn testing back into actual verification.
Really interesting point! π
I think one of the biggest dangers with AI-generated code is that it can look perfectly clean and pass tests while still hiding serious problems.
That's why I believe the future of AI coding isn't just about generating code faster, but about AI actually understanding the entire project and how everything connects.
Curious what you think: could project-aware AI assistants help prevent these kinds of issues, or will human code review always be the most important safeguard?
Thanks @codshotapp! Great question.
I think itβs a mix of both, but they solve different layers of the problem:
AGENTS.mdor shared templates) - it's much less likely to forget anidempotencyKeyor skip adb.$transactionblock that the rest of the codebase uses.Project-aware AI helps make the code consistent with the existing codebase, while human review ensures the change is safe for the business domain. Both are essential!
Really appreciate the detailed answer, John! π
I completely agree. Having full project context is incredibly valuable, but understanding the codebase doesn't automatically mean understanding every business rule or real-world failure scenario.
This is actually something we're exploring with Project Brain, an AI assistant we're currently developing for CodShot.
One question I'm particularly curious about: do you think AI assistants could eventually learn and maintain those unwritten business rules, or will that always require explicit human input?
Would love to hear your perspective!
Project Brain sounds like an exciting direction! Thatβs tackling the hardest and most interesting part of the workflow.
To give you my honest take: I think AI can become incredible at surfacing and formalizing tribal knowledge, but truly unwritten rules will always hit a fundamental boundary without explicit human intent.
Here is how I see that playing out:
Where AI can excel: Mining "exhaust" into living rules
Code alone rarely explains why something is designed a certain way, but git commit messages, rejected PR comments, Slack discussions, and incident post-mortems are full of unwritten invariants. An assistant like Project Brain that indexes that historical context could say:
The trade-off barrier (Why models can't just guess)
At the end of the day, business rules aren't mathematical laws. They are business compromises (e.g., "Do we accept the risk of slight over-crediting to avoid blocking users during checkout outages?"). A model trained on code patterns can only guess the statistical average of what other companies do, which often clashes with a specific startup's deliberate trade-offs.
From mind-reading to active interviewing
Instead of AI magically divining unwritten rules in silence, I think the real breakthrough will be assistants acting as active inquisitors. When a diff touches critical state, the assistant shouldn't just generate the code, it should prompt the engineer: "If payment succeeds but provisioning fails here, should we trigger an automated refund or alert an admin?"
Once the human answers, the AI can codify that invariant into the project memory so it never slips through again.
Curious how youβre structuring the context ingestion for Project Brain. Are you looking mostly at git history/code graphs, or pulling in wider workspace discussions too?
Really appreciate your thoughtful response, John! π
I completely agree, especially with your point about AI becoming an active interviewer rather than just making assumptions.
That's actually very close to the direction we're exploring with Project Brain.
Right now, we're focusing on understanding repository structure, relationships between files, and building persistent project knowledge that can evolve over time.
Git history, PR discussions, and broader workspace context are definitely interesting directions, but we're approaching this step by step.
I think the real challenge isn't just making AI understand code. It's helping AI understand why the code was written that way.
Out of curiosity, if you could give an AI assistant access to one additional source of project knowledge beyond the code itself, what would you prioritize?
If I had to pick just one, it would definitely be resolved PR review discussions - that's usually where trade-offs, rejected alternatives, and the real "why" behind the code are debated before it lands.
Starting with repository structure and file relationships is a very solid foundation. Best of luck building out Project Brain, looking forward to seeing how it develops! π
That's a really valuable insight, John! π
You're absolutely right. Understanding why certain decisions were made could be just as important as understanding the code itself.
We'll definitely keep your suggestion in mind as Project Brain evolves.
Thanks again for the thoughtful discussion and encouragement! Really appreciate it! π
The question you ask every PR to answer, what happens if this code fails halfway through, is the most useful line in the checklist. Your upgradeSubscription example passes every local check and still breaks three invariants at once, and none of them would stand out in a 600 line diff read at the end of the day.
I would push it one step earlier. The failure-mode answer works best when it is written before the code exists, as part of the task: charge once, plan and credits change together or not at all, a retry does nothing. Then the reviewer checks the diff against a short written definition of done instead of reconstructing intent from a clean looking implementation.
nyaomaru's phrase "understanding debt" fits here, and a lot of it comes from work that never had a finish line written down. Have you seen teams keep that failure-mode line in the ticket rather than the PR description, and did it change what got caught?
100% agreed. Shifting that question into the ticket/spec is where it actually makes the biggest impact.
When teams define explicit failure modes and invariants in the issue before touching any code, two things change immediately:
The biggest issue with keeping this solely in the PR description is post-hoc rationalization. Once an agent generates 500 lines of clean-looking, green code, developers subconsciously write the PR description to justify what the implementation already did rather than what the business invariants actually required.
Moving the failure modes to the task level creates an independent contract before any code is generated.
And then there are security implications. See my todayβs post