
Imagine a cosy coffee shop in Berlin that makes really good coffee. One of those places where almost every table is occupied, people are settled in with their drinks, and there’s usually somebody looking around to see whether a seat is about to become free. Good enough that, when the doors open, there’s already a queue for flat whites, cappuccinos, and probably a croissant that wasn’t part of anyone’s original plan.
The place belongs to Sam, who has two employees, Jeff and Elon. Sam is happy to have a busy café, but he also wants to keep staffing costs down, so these are the only two working behind the counter. They’ve divided the work between them: Elon takes orders and payments, while Jeff prepares the drinks. For Jeff, that means grinding the beans, pulling the espresso, preparing the milk, pouring, and calling the customer when the coffee is ready.
Both are busy. Both are focused. Nobody is standing around avoiding work.
Elon can take an order faster than Jeff can prepare it. For our imaginary café, let’s simplify this into one order a minute at the till and three minutes per drink at the coffee machine, with no overlapping preparation. At that pace, Elon can accept up to 60 orders an hour, while Jeff can finish 20 drinks.
As long as the arriving customers are within what Elon can handle, he can keep the queue in front of him very short. We might even congratulate him for it. But we still have a queue, just not in front of Elon anymore. Those customers have come here to drink a coffee, not simply to place an order, and now they’re waiting beside the counter with their receipts. They aren’t particularly happy about it, and Sam is worried that they might not be so keen to come back.
In this setup, preparation limits how many coffees the shop can hand over. We could push Elon to take orders even faster and make the entrance queue look excellent, but that wouldn’t help Jeff finish more drinks. We would be moving customers to the next queue more quickly, not getting their coffee to them more quickly.
Sam looks at the receipts beside Jeff and gets the two of them together to discuss another arrangement. They decide that Elon should join Jeff at the coffee machine instead of staying at the till between orders. Jeff will handle the espresso while Elon prepares the milk. When a flat-white order comes in, Jeff can pull the two espresso shots while Elon heats the milk, rather than Jeff doing the whole sequence on his own. In our café, that makes preparation faster once they get going.
Then another customer reaches the till. Elon puts down the milk jug, takes the order and payment, and comes back. A moment later, there’s another customer, so off he goes again. Now drinks can be prepared more quickly when both are working on them, but Elon keeps moving between two jobs. Sometimes Jeff is ready with the espresso and waiting for the milk; sometimes he takes over the milk as well to get the drink finished. They’re both trying to keep things moving, but the arrangement becomes a little chaotic, and the entrance queue moves less smoothly than when Elon stayed at the till.
Has this arrangement solved the bottleneck, or have we moved it somewhere else?
We need to look at the whole journey to answer that. How many customers receive their coffee, and how long do they wait from joining the queue to holding the cup? A faster preparation step is useful, but it doesn’t tell us the result by itself.
Sam is happy with part of the improvement: when Jeff and Elon can stay together at the machine, the drinks are prepared more quickly. He doesn’t want to lose that, even though customers are now waiting longer to place their orders. He also still doesn’t want to hire another person. So he decides to try a little technology, and the next experiment is a self-service ordering screen. Customers can order and pay there, which means Elon no longer has to keep leaving the milk jug to go back to the till.
Now they can work more like a little production line: the order comes in, Jeff prepares the espresso, and Elon prepares the milk. For some drinks, they might even work on separate orders at the same time. If two customers order Americanos, for example, Jeff and Elon could each prepare one, so two orders are in progress rather than one person repeatedly stopping to take payments.
That could help. It still depends on things we’ve deliberately simplified: whether the machine supports that parallel work, whether the ordering screen can handle arriving customers, and whether people can use it without needing help. A screen taking the orders doesn’t mean there will never be a reason for one of them to step away.
It’s tempting to say the customers are now the bottleneck because they have to choose a drink and navigate the menu. Maybe ordering really is the next limiting step. Or maybe the espresso machine is. Or maybe the shop can now comfortably handle its normal demand and the remaining wait is small enough that it isn’t worth another experiment.
A step taking time doesn’t, by itself, make it the bottleneck. In this article, I mean a part of the system that limits how much useful work we can finish under the demand we’re placing on it. Queues help us investigate that, but the longest-looking queue isn’t automatically the answer either.
There is a management idea behind this: Eliyahu Goldratt’s Theory of Constraints. It focuses on what prevents the whole system from reaching its goal, rather than treating improvements to each individual part as the goal. For Sam, that brings us back to the customers: getting very good at taking their orders isn’t enough if they’re still waiting for their coffee.

Anyway, we came here to talk about software engineering teams. Getting a coffee might be a good idea before reading the rest of this, but running a café isn’t today’s topic. Let’s take the same question into a software team.
I’ve worked in software engineering for close to 15 years. That’s been enough time to experience a few bottlenecks, including one I’d rather not be proud of: the lead becoming the person everyone has to wait for.
Back at the café, imagine that Sam has kept the authority to decide when the plan can change. Elon finishes an order, notices Jeff could use help restocking, and has some time to do it. But Sam hasn’t told him to leave his current task, so he waits. The work is there, and Elon could help with it, but the next move still needs to come from Sam.
Then the coffee beans are almost gone and the delivery hasn’t arrived. Sam is off sick. Jeff and Elon know they could call another supplier, but neither knows whether they’re allowed to change the order or spend the money. They can see the problem, and they even have an idea of what to do about it. What they don’t have is permission to go ahead without reaching Sam first.
Sam may have two people running the counter, but he has kept too many of the decisions to himself.
Jeff and Elon know how to do the work. They don’t know how to proceed without Sam.
In a software team, this can look surprisingly ordinary. Someone finishes a task but doesn’t know which ticket they can pick up until the lead assigns it. A stakeholder asks an urgent question, but nobody feels able to answer confidently. A decision waits for an alignment conversation that only the manager is “supposed” to have. Eventually, someone messages that manager during sick leave, not because the team has stopped being competent, but because it doesn’t know how far it can proceed on its own.
I’ve worked in this setup as an individual contributor. Unfortunately, I’ve also been the lead in it.
Reflecting on those times as an individual contributor, I remember that it wasn’t a comfortable environment to work in. You have an idea, but before you can try it, you feel you need to ask. You can carry out a decision, but you don’t get much space to make one. The cost isn’t only the time spent waiting for an answer. It also affects how much you learn from the work. At some point, I need to make decisions, get some of them wrong, and understand why. If somebody else always makes the decision, I’m missing part of that experience.
Being the lead made the same problem harder to notice. From inside the setup, answering every question can feel like being helpful. You’re involved, you have context, and people come to you because you can move things forward. It’s quite easy to focus on all the answers you’re providing and miss the fact that the team keeps having to stop until you provide them.
Then you get a little distance, see that things don’t work the same way when you’re not there, and it finally clicks: oh damn. I’m the bottleneck.
One of the best ways to see that potential dependency is to go on holiday, but without your laptop or work phone. (Managers who become bottlenecks, including the kind of manager I used to be, can end up living at work and carrying work into the rest of their lives. So, of course, they may pack the laptop and phone for the holiday too. At some point, though, they might have to leave them behind—maybe because their partner insists that, this time, they actually take a holiday.)
A week away can be a useful diagnostic: look at what happened while you were gone. The first sign: has somebody already tried to reach you because work is waiting for a decision only you can make? Maybe they’ve even contacted you on social media because they couldn’t reach you through your work phone. If the team can’t move forward until you answer, then yes—sorry, you look rather like the bottleneck.
Let’s say that was the extreme case and your colleagues respected your holiday, which is great. But what happens when you come back? You check the chat and find plenty of personal messages, some of them practically shouting, others asking for immediate support. Several meetings have already been scheduled for early Monday morning because people desperately need to ask you something. If those questions concern work that nobody could move forward without you, the same dependency is still there. Your colleagues just waited until you returned before putting it in front of you.
What’s the alternative? You open your laptop and, obviously, there are still plenty of emails and messages, including personal ones. But hey, you know what? You still have some time to catch up before the next meeting. Nothing urgent has been left waiting specifically for you. The team kept progressing. Maybe there were issues, which is completely normal, but somebody was able to take the lead and move things forward.
Looks nice, right? Unfortunately, that sort of return is hard to have when the manager has become the person everything waits for. Quite honestly, this setup may even be most damaging to the manager, who doesn’t really get to step away from the dependency either.
I don’t think this is usually intentional, and I don’t imagine many managers would be happy to become the bottleneck—unless being in control of every decision is what they actually want. But sometimes that thin line between managing things yourself and enabling the team to manage them can be crossed without you noticing.
And sometimes the whole team waited for one expert
There is another version that I’ve found particularly risky as a manager (not the bottleneck manager this time): one individual contributor becomes the person almost everyone has to wait for. The manager might have stepped back from making every decision, but if the work now waits for somebody else, we haven’t really got rid of the dependency.
Back at the café, Sam comes back and tells Jeff and Elon they can handle routine decisions themselves. Elon can use a quiet moment to help with stock without asking Sam what to do next. If an ordinary problem comes up, the next step doesn’t have to be a call to the boss. That should make life easier for everyone.
Then the coffee machine misbehaves. Elon can make the familiar drinks, but he doesn’t know how to get the machine working properly again. He doesn’t need Sam’s permission to deal with it this time. He needs to know what to do, so he asks Jeff.
Jeff is the only one who knows how to adjust the grinder, recover the machine when it misbehaves, and prepare all the less-common drinks. If Jeff can’t step in straight away, Elon can ask Sam instead. Sam doesn’t know the answer either. He can give Elon permission to sort out the problem; he can’t give him the knowledge he needs to do it.
Even if Sam changed his mind and hired another pair of hands, that wouldn’t necessarily remove this dependency. It might help them get through the familiar drinks, but if the new person also needs Jeff whenever something unusual happens, there are now two people waiting for Jeff instead of one.
Software teams will have different levels of experience, system knowledge, and productivity. I don’t have a problem with that, as long as we maintain a good bar for the team. An experienced engineer can help set that standard and help others understand what good work looks like. The problem starts when the rest of the team can’t judge the work against that standard without first asking the person who helped establish it.
For example, we might have one engineer who understands how several parts of the system fit together. Other people can work independently within familiar areas, but when a change crosses those boundaries, they really need that engineer’s context. There is knowledge missing, much like there was for Elon at the machine.
But sometimes we also build a habit of getting that person’s approval for almost everything. Someone has thought through a design, an answer for a stakeholder, or a change to a familiar component, but still doesn’t feel able to share it until that engineer has confirmed it. We haven’t necessarily helped everyone get better at judging their own work. We may just have made “ask this person” the next step, even when someone else could reasonably make the decision.
Now imagine a junior engineer needs help, the product manager needs an explanation, a stakeholder needs an answer, and another engineer need a decision before another piece of work can continue. Four perfectly reasonable requests, all going to the same person. If the questions keep arriving faster than the engineer can answer them, there is another one waiting as soon as they’ve finished helping someone. That engineer can be working hard the whole time while the rest of us still can’t get on with our work.
As the manager, I can collect those questions, work out which one is most urgent, and ask the expert to look at that one first. Then somebody checks back because they’re still blocked, and I go back to the expert to ask whether they’ve had a chance to look at that question yet. In that situation, I’m not really distributing work across the team anymore. I’m managing the order in which we all wait for the same engineer. And if I need an answer myself, I don’t have a separate way through. I join the queue.
I’ve experienced both setups, and I can find this second one harder to untangle as a manager. In the first case, the team may already know what to do but lack the authority to do it. Here, some of the knowledge really may exist in only one person’s head. The manager can want the team to work independently and still be unable to help them take the next step.
That doesn’t make the experienced engineer the problem. Their knowledge is valuable, and they may be trying to help everyone who comes to them. The hard dependency on their availability is what worries me, particularly when even the manager can do little more than pass another question along.
Both arrangements have something in common, though. There are capable people with work they could be doing, but the next step keeps waiting for somebody else’s context, authority, or judgment. Giving everyone more work wouldn’t, by itself, remove that wait. Before worrying about how quickly people can work, I need to understand why they keep having to stop.
Generative AI has changed part of this picture. If the question is how a piece of code works, finding the one person who remembers it isn’t necessarily our only option anymore.
An engineer can ask a repository-aware agent to trace a request across services, explain an unfamiliar module, or find the tests around a particular behavior. The agent can inspect what is actually in the repository and give the engineer a starting point to investigate, rather than leaving the question waiting until the expert is available. Sometimes the useful context really is already there in the code, even when the documentation isn’t.
That doesn’t mean all the missing context has suddenly become available. The repository can’t necessarily tell us why a commercial exception exists, which commitment was made to a stakeholder, or whether we are allowed to change a policy. We still need to check the explanation and notice what it hasn’t answered. Finding the machine instructions wouldn’t tell Elon what Sam promised a supplier, and it wouldn’t automatically give him permission to change that agreement either.
So AI can help reduce a knowledge dependency without removing an authority dependency. That is still a useful change. It just isn’t the same as saying that a prompt has made every kind of dependency disappear.
I remember when ChatGPT’s public preview arrived in 2022. I’ll shamelessly admit that I hadn’t been following all the developments in AI models, and my first reaction was, “Oh my God, another bubble like the metaverse or NFTs.” But once I started using it, I realised, “Hey, this is different.”
It didn’t take much to see how it could be useful for coding. Like other developers trying it out, I started with copy and paste: give it some code, ask it to fix something, and bring the answer back into the editor. I didn’t even know about repository-aware tools at that point. Of course, moving code back and forth like that was quite inefficient, but I’d still say it was better than nothing. Once company-approved wrappers for accessing the models became available, I started using them in the profesiional life more.
But using the tools and feeling comfortable admitting it were still two different things. People were still evaluating what to make of them. From what I saw, poor-quality AI-generated code, or “AI slop,” was a particular red flag for senior engineers. At the same time, I could also see people getting more done with the tools. That didn’t make the quality concerns disappear. Even so, openly crediting Copilot in a commit message could still feel like admitting something we were supposed to hide.
Then repository-aware tools changed what I could ask for. They could work with the codebase, keep relevant context available, and work through a task in a loop instead of only replying to one pasted snippet. Cursor was one of the tools that really caught my attention. I remember discussing it with a colleague and us saying, “Hey, those guys are doing some real black magic.” It was impressive, but it was also hard to understand what was happening under the hood.
I’ve seen that shift across Copilot and agents running in the terminal too, while the models behind the tools have kept improving. For me, writing code by hand is no longer the default way to start.
Now compare that with the engineering conversations I see today. The tools aren’t just somewhere to paste a question anymore; repository-aware agents can work with the code we are discussing. The shift I notice is from asking for an answer we might use to giving an agent a piece of work and then discussing what it has found or done. Saying that an agent helped is becoming an ordinary part of the conversation.
“My agent thinks the issue comes from this.”
“Let me give that requirement to my agent.”
“My agent will review your PR.”
We moved from quietly trying the tools to openly steering them. Different editors, different harnesses, different ways of keeping context available, but increasingly the engineer is directing the work rather than typing all of it.
I like that change. For practical implementation work, I’d usually rather use an agent and spend my attention on steering and checking the result than write every line by hand. But now we have to ask the coffee-shop question again. If producing code gets faster, what limits the journey from a problem to a change we can safely use?
I don’t see a shortage of possible problems to work on in the teams I can observe. Requests can come from product, stakeholders, or the engineers themselves. Expectations have even gone up, more things feel worth attempting, and more requests are described as being only a few prompts away.
I don’t mean that AI has created all these problems. It’s more that people seem more willing to try solving them now since implementation looks easier. That’s how I read what is happening around me, not a measurement of every team’s demand.
For our hypothetical team, let’s assume there is plenty of work arriving at the start of the process. This team is working on a website where readers browse a catalogue of books. The catalogue doesn’t load every book in one response; the page asks an API for a group of books and requests another page when the reader wants to browse further. The API already has a configured maximum page size, but a request for a larger page can still produce more books than that maximum.
A ticket arrives asking us to fix that. At first, the requirement looks small enough to hand straight to an agent:
Make sure a response never contains more items than the allowed page size.
A rough journey might be: receive the problem, prepare the details, agree on the work, implement it, review it, verify it, and release it. Your team might combine these steps, run some in parallel, or have a different release policy. Tests should already be running during implementation and review, not wait politely for their turn at the end.

The first step is to refine that requirement, not immediately ask an agent to edit the code. We know that the current response can exceed the limit, but we haven’t yet collected the details that would let another engineer pick up the work without repeating the whole investigation.
Where does the limit come from? What happens when someone requests a larger page? Should we reject the request or cap it? What should happen when fewer items are available? Which existing behavior must stay the same?
Our book-catalogue ticket could be beautifully written and still leave two engineers planning different changes. One might cap the number of books returned; another might reject an oversized request and require the website to ask again with a smaller page. Both are trying to enforce a maximum, but those choices affect the caller differently. Making the description quicker to produce doesn’t settle which behavior we want.
An agent can help turn our page-size request into a useful refinement draft. It can inspect the existing path, find related tests, ask questions, and propose acceptance criteria. Instead of starting a discussion with only the original sentence, we can arrive with an explanation of where the limit is read, which part of the response uses it, and what behavior needs to stay the same.
It might notice that “maximum page size” is read in two places, or ask whether an existing error response should be preserved. It can also confidently propose the wrong behavior. The useful part is that we have something concrete to examine and challenge, not that the first detailed answer has become correct just because an agent produced it.
I don’t think writing that first detailed version of the ticket is necessarily our main constraint anymore, at least where the relevant code and context are available. But once we have it, what happens next?
In many teams, it goes into a refinement meeting. The team asks questions, checks assumptions, challenges a design, and clarifies the work with product or stakeholders. Sometimes they also estimate the ticket. The intention is that whoever picks it up afterwards understands enough to proceed, rather than having to reconstruct the conversation from a few sentences on the board.
Thus, refinement is doing something beyond writing a better ticket. We are building a shared understanding of what is about to change and giving people space to question it before they have to implement or review it.
I always ask an agent to review or challenge a ticket before discussing it, and I’d encourage my team to do the same. That can improve the preparation and bring useful questions into the room. It doesn’t mean the team has agreed on the answer just because everyone arrives with a more detailed document, or because each person’s agent is comfortable with its own version.
Let’s give our hypothetical team an hour for refinement and allow 15 minutes per ticket. Imagine six or seven people in the conversation, with a few of them bringing work they would like to start. Four tickets fit into that hour on paper. That’s the happy path: the right people are there, the questions are answerable, nobody finds a surprising dependency, and each discussion ends with the team comfortable proceeding. We also somehow finish on time.
Even writing that sounds a little optimistic.
Now suppose we wanted to start six tickets, but the team only got three to a point where it was comfortable proceeding. The other three stay where they are. They might have detailed descriptions and a proposed implementation, but if this conversation is required before work can start, they still aren’t ready to move. Meanwhile, more requests arrive, each of which looks quite achievable with an agent.
One difficult meeting doesn’t prove that refinement is the new bottleneck. But if it is the mandatory gate for starting work, and requests keep arriving faster than the team can agree on them, this becomes a serious candidate. The time needed to prepare a draft may have fallen while the capacity to make shared decisions has barely changed.
This can also make new requests harder to accept, even when implementation looks small. I’ve heard the argument that, in the time spent discussing something, we might already have delivered it twice. I understand the frustration. Sometimes it points to unnecessary ceremony, and sometimes the discussion is uncovering a decision that the code cannot make for us. Either way, the fact that the implementation looks quick doesn’t tell us which of those situations we’re in.
Calling both activities “refinement” can hide that difference. We might be waiting for someone to investigate the problem and prepare a draft, or we might already have the draft and be waiting for the people who need to agree on it. Those are different reasons for work to be stuck, even if the ticket sits in the same column on the board.
I’m not proposing a new meeting format today. I have some thoughts about that, but first I want to be clear about what is waiting and why. An agent writing a very impressive ticket doesn’t, on its own, tell us that the team can move more work through this part of the process.
Let’s say our book-catalogue ticket is one of the three that gets agreed. For this one, the team decides to cap oversized requests, not reject them. With 50 books available, a requested page size of 30 and a maximum of 20 should return the first 20. If fewer books are available than the allowed size, we return what is available. We are fixing the page-size rule, not changing how the reader moves between pages.
With that settled, the ticket reaches the board, the next available engineer picks it up, and they start working with an agent. After some changes, checks, and another iteration, we have a pull request ready to review. It is meant to enforce the rule the team just agreed.
Excellent. Who reviews it?
Another agent, obviously.
And yes, agents can help with very good review. A separate session can look for defects, challenge assumptions, and propose tests. The author can respond to those findings and iterate before asking a teammate to look. That teammate may use an agent too, so now one agent has written the change, another has checked it, and the reviewer’s agent has also said it looks good.
That can be useful work, but it isn’t automatically independent confirmation that the implementation does what we agreed. All three can read the same page-size rule and still overlook the same mistake. They might notice that the maximum is passed to the page selector and assume it is enforced there, without checking what the selector actually returns when the request exceeds it. Agreement between the reviews doesn’t tell us whether somebody challenged that shared assumption.
This is where I still want a person to take accountability. That doesn’t mean every line must be written by hand; I would usually prefer the opposite. It means that, if I submit a change, I need to be able to explain what it does and why I’m comfortable with it. Using an agent changes how I produce the code, not whose change I’m asking the team to accept.
My own routine includes waiting for the pipeline, which has checks such as unit and component tests, and walking through the change before asking for review. When I don’t understand something, I ask the agent to explain it. Sometimes that leads to another iteration because, after understanding the implementation, I want it to be different. The point isn’t to get through the explanation as quickly as possible and collect another thumbs-up. I want to understand the change well enough to stand behind it.
For our page-size example, that would mean being able to follow the request from the supplied size to the returned items, not just point at a parameter named max_size. If somebody asks about code in my PR, “Sorry, my agent wrote that, I have no clue” isn’t an acceptable answer for me.
Doing that preparation is also a matter of respect for the reviewer. I’m asking them to spend attention on a change I’m proposing. I should have spent some attention understanding it first, rather than passing the whole job of making sense of it to them.
It takes time, though. Code generation may already be finished while I’m still working through what was generated. My ability to understand and stand behind the change can become a constraint on how much work I can safely submit. After that, a teammate still has to find the time and context to review it. Finishing my part doesn’t make their attention immediately available.
If several engineers can each generate changes faster than the team can assess them, the ready-for-review queue grows. Our book-catalogue fix can sit in that queue with the code already written while the deployed API still returns more books than the configured maximum. From the reader’s side, the problem hasn’t gone anywhere. Making it easier to open the next PR doesn’t, by itself, get the existing ones understood and released.
The human work isn’t automatically well designed just because I think it’s necessary. Reviews can be oversized, repetitive, or waiting on the same expert we met earlier. We might have useful review work mixed with avoidable waiting. But removing the review is a risk decision, not evidence that the constraint was harmless.
A team may deliberately allow more agent-to-agent review for bounded changes. If that works within its agreed risk and accountability model, great. I haven’t seen enough to make me comfortable treating two agents giving each other a thumbs-up as a general replacement for a meaningful human review. A human approval doesn’t guarantee correctness either. Whoever is involved, the review has to be more than another button in the process.
Even after the review, verification can become another queue: waiting for a pipeline, an environment, somebody’s manual check, or an answer about what a test actually proves. Tests should already have been running while we worked on the change, of course. The question now is whether the checks give us enough confidence to release it, or whether we’re still waiting for something important to be verified.
There is another problem here too: a check can finish successfully without answering the question we thought it answered. Our catalogue response could contain the expected items field and still include 30 books when the maximum is 20. A check that the response has the right shape isn’t useless. It just doesn’t establish that the page-size rule was enforced.
I have a recent example that made this less theoretical for me.
I was paying more attention to the implementation going to production than to the unit tests around it. I used agent reviews, got the approvals, and merged changes. The functionality looked fine.
Later, we found a bug that should have been easy to catch with a meaningful unit test. That surprised me because the reported unit-test coverage was around 90%. I would have expected a case like that to be covered, and at first the percentage seemed to support that expectation.
Looking more closely, I found too much mocking around the behavior that mattered. A test could appear to cover the case while mostly checking configured mocks and the surrounding wiring. The answer we wanted was already supplied by the mock, so the test wasn’t really challenging whether the implementation could produce that answer.
I won’t reproduce the private code here. We can see the problem with a small synthetic Python example using our page-size rule and a list of integers, not company data. The 90% figure belongs to the earlier experience; it isn’t a coverage result for the example below.
In pagination.py, suppose the maximum is accidentally ignored:
def select_page(items, requested_size, max_size):
page_size = requested_size
return items[:page_size]
def create_response(items, requested_size, max_size):
return {
"items": select_page(
items, requested_size, max_size
)
}
The first test below checks that the response uses the page selector and passes it the expected arguments, including the maximum of 20. That’s a legitimate wiring check. But the test replaces the selector with a mock that returns the first 20 items regardless of what the real function would do. It can confirm that we made the right call without confirming that the real selection respects the limit.
The second test, in the same test_pagination.py file, calls the real selector. It gives it 50 available items, requests 30, and sets the maximum to 20. Then it checks both how many items came back and which items they are:
import unittest
from unittest.mock import patch
import pagination
class PaginationTests(unittest.TestCase):
def test_response_uses_selector(self):
items = list(range(50))
with patch(
"pagination.select_page",
return_value=list(range(20)),
) as select:
response = pagination.create_response(
items, 30, 20
)
self.assertEqual(
response, {"items": list(range(20))}
)
select.assert_called_once_with(items, 30, 20)
def test_page_cannot_exceed_limit(self):
page = pagination.select_page(
list(range(50)), 30, 20
)
self.assertEqual(len(page), 20)
self.assertEqual(page, list(range(20)))
Against the broken implementation, the response test passes. It sees the expected 20 items because the mock supplied them, and it confirms that the maximum was passed to the selector. The behavioral test fails because it actually runs the selection: the function returns 30 items instead of the allowed 20.
The maximum has made it all the way from the response wrapper into the function, but the assignment still ignores it. Replacing that assignment with the following makes both tests pass:
page_size = min(requested_size, max_size)
This example is deliberately small. It demonstrates a missing behavioral check, not a complete pagination API. It assumes nonnegative integer sizes and leaves input validation and page navigation out of scope.
The problem isn’t that mocks exist. A test that mocks an external email service may be exactly what we need, and checking that one component calls another correctly can be useful too. The question is whether, somewhere in the suite, the rule we care about is exercised against real implementation and an independently chosen expected result. The mock shouldn’t be the only place where the correct answer exists.
Martin Fowler’s explanation of test coverage is useful here: coverage can help locate code that wasn’t exercised, but a high percentage isn’t a quality score for the assertions.
Our little selector shows why. If we requested 20 items with a maximum of 20, both the broken assignment and the corrected one would return the same result. We could execute every line and still not distinguish them with that case. Requesting 30 against a maximum of 20 is what challenges the rule, rather than simply confirming a case in which the two implementations happen to agree.
AI can help write that test, run it, and investigate its failure. It can also help produce a test that repeats the implementation’s assumption. The useful question is whether the test would fail for the bug we’re worried about, not whether a human or an agent typed it. That’s part of understanding the change too, and in my earlier example I hadn’t given it enough attention.
In our hypothetical team, that failed behavioral check sends the book-catalogue ticket back to implementation rather than on to release. The author fixes the assignment, reruns the relevant checks, and asks the reviewer to look at the correction. That little return journey is easy to leave out when we draw a nice straight line from coding to done. It still takes somebody’s attention.
The little Python example doesn’t build or deploy the website. To finish our hypothetical ticket, imagine the corrected change going through the team’s response-level checks as well: with 50 books available and the maximum configured as 20, a request for 30 comes back with the first 20. The team has checked the rule in the response path, not just supplied that answer through a mock.
After the correction is reviewed and the required checks pass, the team releases it using its normal process and checks that the deployed API respects the same limit. The catalogue can still fetch another page when a reader wants more books; this change only caps how many one response contains. Only then does the person browsing the catalogue get the behavior the team agreed on. The agent finishing the implementation wasn’t the end of this ticket.
We could remove the author’s walkthrough, skip the reviewer, and trust the green pipeline. That would certainly make some changes reach production faster. It would also change the level of risk we’re accepting. We wouldn’t just be removing waiting time; we’d be removing some of the work that was supposed to establish whether the change was safe to use.
I don’t want to pretend that every human checkpoint is good, or that more review always produces a better result. For our page-size change, the useful question is what convinces us that the limit actually applies, and who is prepared to stand behind that conclusion. A meeting and two approvals that never address the rule wouldn’t help much, however carefully we followed the process.
At the same time, useful work can still limit the flow. If understanding and reviewing changes takes more capacity than we have available, it can become the constraint even when we have good reasons for doing it. Calling it a bottleneck doesn’t, by itself, tell us that removing it is the right answer.
The argument I find unconvincing is, “Don’t worry about it; we have AI, so we can refactor later.”
Of course AI can help refactor. But if an incorrect result has already reached a customer, refactoring afterwards won’t undo it. We may also be changing a live system in which several other unchecked assumptions have accumulated. I’m not comfortable assuming that a future agent will make all of that cheaper and easier than understanding the change before we release it.
I’m willing to spend some of the time saved in implementation on understanding and verification. I see that as part of delivering the change, not an embarrassing leftover from the days before agents. It does mean that the time we saved writing code won’t all become time available to start another ticket. Some of it goes into making sure the first one is actually ready, and that work needs to be visible when we talk about capacity.
DORA’s 2025 research summary makes a related point: the return from AI depends on the organizational system around it, not only on the tool. That doesn’t prove where our particular team’s bottleneck is. It is a useful reason to stop treating faster code generation as the whole story.
So, what is the bottleneck now?
For our hypothetical page-size change, we have several places worth looking:
| Work waiting | Possible constraint | What would help distinguish it? |
|---|---|---|
| Detailed tickets waiting to be agreed | Shared decisions or a mandatory refinement slot | Are otherwise actionable tickets repeatedly waiting for that conversation? |
| Changes waiting for the author’s walkthrough or a reviewer | Available attention and understanding | Are ready changes arriving faster than people can meaningfully assess them? |
| Reviewed changes waiting for confidence or release | Checks, environments, or an approval boundary | What is the change waiting for, and does that step verify the behavior we need? |
These aren’t three proven bottlenecks that every team now has. We haven’t measured a team’s flow here. More than one queue can exist, some work can be deliberately deferred, and the main constraint can change as demand or capacity changes. They are places I’d investigate because of what the work is waiting for, not a diagnosis we can make from the names of the columns on a board.
For our book-catalogue ticket, faster implementation wouldn’t have helped the reader while the work was still waiting for agreement, review, or verification. We could have a much tidier ticket and a much more impressive diff while the deployed API continued to return an oversized page. Making one earlier step look more productive doesn’t necessarily get the agreed behavior into somebody’s hands.
If we’re comparing similar-sized changes over the same period, a team that can implement six but only agree on three won’t automatically deliver six. Nor will opening six PRs mean six changes can be delivered if the team only has capacity to meaningfully review three. The extra implementation capacity may turn into work waiting for the next step, rather than into another correct response for someone browsing the catalogue.
That’s the part I want to identify before we start changing the process again. Not who looks busiest, and not which agent can produce the most impressive diff, but what is preventing a problem we want to solve from becoming a change we can safely use. That includes the time spent waiting to agree on the work, not just what happens after someone starts writing code.
I have a few experiments and thoughts about what to do next. Let’s keep those for the next post. For now, I’d follow one real change all the way from the initial request to behavior somebody can actually use, asking where it spent its time waiting, what it was waiting for, and whether that was the constraint that mattered.