Designing a Feature an AI Coding Agent Cannot Improvise / Sep 22, 2026
The last time I got this wrong, the requirements were good.
I had written them properly, in the shape I argued for in the last post. WHEN a customer cancels inside the return window, THE SYSTEM SHALL refund the unused portion. IF the provider rejects the refund, THEN THE SYSTEM SHALL keep the order open and flag it for follow up. Every case I could think of had a sentence attached to it.
The agent built the whole thing in one pass, and the diff matched my criteria line for line. Two weeks later an order sat in the database marked as refunded, with no money having left the company.
The refund had failed at the payment provider. Our own record was already written, because the agent had chosen to update the order first and call the provider second. Nothing in my criteria said which came first. It was a reasonable guess, and it was the one decision in the feature that actually mattered.
Requirements told it what must be true. They said nothing about what exists, or in what order things change. So it decided.
Requirements are not design
This is the part I had wrong for a long time. I thought a good enough set of acceptance criteria was a specification. It is not, and the gap is easy to miss because both documents describe the same feature.
Requirements are claims about behavior. They say what is true when the thing works, and what is true when it fails. Design is a different kind of statement. It says what exists, what each thing can be, and the order in which they change.
Hand an agent criteria alone and three decisions are still open. It has to invent a schema, because something has to store the refund. It has to invent state names, because something has to represent a refund that has been asked for but not yet paid. And it has to invent a write order, because two things have to happen and one of them has to go first.
It will make all three in about a second, and the code will pass your criteria, which is the part that makes this hard to catch. My tests were green. The behavior was wrong in a way no test I had written could see, because I had never written down that the provider call comes before our own record.
The hard part did not disappear when writing code got cheap. It moved one step at a time, and this is the step after requirements.
The data model comes first
Start here, always, because the data model is the most expensive thing to change later and every other decision inherits from it.
One question exposes most of it: can this thing happen more than once?
For the refund, I had a column. Something like refunded_at, a timestamp on the order,
null until it was not. Reasonable, and wrong in a way that only shows up later. A column
cannot hold a partial refund. It cannot hold two attempts, one failed and one succeeded.
It cannot tell you why the failed one failed. The moment a customer returns one of two
items, the model has nothing to say.
A refund is a row. It has an amount, a status, a provider reference, a created timestamp, and a link to the order. Partial refunds become free, because two rows against one order is already the answer. A failed attempt stops being a lost event and becomes a record you can look at.
Two smaller decisions belong here too, and both are cheap now and awful later. Money is stored in integer minor units, decided once, in one place, because rounding is not a detail you retrofit into a table that already has orders in it. And absent, null, and zero are three different facts. No refund requested, a refund of unknown amount, and a refund of nothing are not the same thing, and if your model cannot tell them apart then neither can anyone reading it.
Then every state, including the ones you would rather not draw
An undesigned state is a state the agent invents. That is the whole reason this section exists.
The list to walk, for anything with a pending middle: empty, loading, partial, error, offline, success. Most people design success and error and stop. Partial is where the bugs live, and offline is where the embarrassing ones live.
Here is the refund, fully stated:
| State | True when | Customer sees | Can become |
|---|---|---|---|
| requested | Row written, provider not yet called | Refund started | sent, failed |
| sent | Provider accepted, not yet settled | Refund on its way | succeeded, failed |
| succeeded | Provider confirmed settlement | Amount and date | nothing |
| failed | Provider declined, with a reason | We could not refund this, your order is still open | requested |
| unresolved | Call made, no answer received | We are checking this refund | succeeded, failed |
That last row is the one almost everybody leaves out, and it is the reason I am writing this post.
A timeout is not a failure. A failure means the provider told you no. A timeout means the provider told you nothing, and the money may or may not have moved. Those are different facts and they need different states, because if you model a timeout as a failure then your retry logic will refund the same order twice. Model it as success and you keep a customer's money. The only correct behavior is to admit you do not know, and to make a person find out.
Notice the third column. Every state has something the customer reads. That is not decoration. An undesigned state gets a spinner that never stops, or a blank screen, or worse, a cheerful confirmation of something that has not happened.
Order of writes, and the word idempotent
Two things have to happen. One is in your database, one is at somebody else's company, and you control the order.
The rule is short: write the attempt before you make the call.
If the process dies during the provider call, and you wrote first, you are left with a row saying a refund was attempted and no confirmation. That is recoverable. Somebody can go and look. If you called first and meant to write afterwards, the same crash leaves money moved and no record of it anywhere in your system. That is not recoverable by looking, it is recoverable by reconciliation, which means a spreadsheet and a bad week.
Commit the evidence, then do the thing that might not come back.
The second half is the idempotency key. Generate one when you write the row, send it with the call, and reuse the same key on any retry. A provider that sees a key it has already processed refuses to do the work twice and tells you what happened the first time. Without it, your retry is a second refund. With it, your retry is a question.
Same two operations, same crash, two very different mornings.
The sequence, written as numbered lines
It does not have to be a diagram. I have drawn plenty of sequence diagrams that nobody opened. Numbered lines get read, and they are enough to settle the argument the diagram was for.
1. Validate the request against the order.
Reject outside the return window, with the window in the message.
2. Insert a refund row: status requested, amount in minor units,
a fresh idempotency key.
3. Commit. Nothing external has been touched yet.
4. Call the provider with that key.
5. On a definite answer, set succeeded or failed, storing the
provider's reason verbatim.
6. On no answer, set unresolved. A person resolves it.
Never a retry loop.
Six lines. The agent now has nothing left to guess about ordering, and the one decision that broke my refund feature is written down where a reviewer can disagree with it.
What to decide, and what to leave alone
This is the judgement call, and getting it wrong in the cautious direction is its own failure. Design every detail and you have written the code in prose, slowly, in a document that is already out of date.
The test I use: decide anything that is expensive to change, or invisible when wrong.
Expensive to change is mostly the data model and anything about money. Invisible when wrong is states, write order, and idempotency, because all three look fine in review and fail only under conditions your test suite does not create.
So decide the data model, the full state list, where money arithmetic happens, the order of writes, what carries an idempotency key, and what a customer reads in each failure.
Leave file layout, function and variable names, which internal helper gets used, how the tests are scaffolded, and library choice inside a constraint you already stated. All of that is cheap to change and obvious when wrong. A reviewer catches a badly named file in thirty seconds. Nobody catches a missing unresolved state by reading, which is why review is the wrong place to be discovering design.
One page
That is the document. A data model, a state table, and a numbered list of writes.
Not an architecture deck, not a template with sections for things this feature does not have. One page you write before the prompt and update when a decision changes, for the features where a wrong guess costs real money or real trust.
You will still get things wrong. You will get them wrong on purpose, in writing, in a place where a second person can see the decision and argue with it, which is the only version of wrong worth having in code you may need to explain months from now.
Next in this sequence is the part that surprises people most: how big a task should be before you hand it over, which almost everyone gets wrong in both directions at once. I am turning all of this into a course on spec-driven development, planned for November, and the waitlist there sees the date and the price before anyone else. The box below is for the writing, and nothing else.
Frequently asked questions
- How is this different from writing requirements?
- Requirements are claims about behavior, what must be true when the feature works. Design is the set of things that exist and the order in which they change. You can have perfect requirements and still leave the schema, the state names, and the write order to be invented, because none of those are claims about behavior.
- Is a design document not just more process?
- It is one page, and only for features where a wrong decision is expensive to undo. A data model, a state table, and a numbered list of writes. If the feature touches money, identity, or data you cannot recreate, that page pays for itself the first time something fails halfway.
- Should the agent write the design?
- It can draft the data model, and it is genuinely good at listing states you forgot. It should not own the order of writes or where money arithmetic happens, because those are the decisions that are invisible when wrong and expensive to reverse. Draft with it, decide yourself.
- Do I need a state table for every feature?
- No. You need one when a thing has more than two states, or when an external call can leave you not knowing the outcome. A settings toggle does not need one. Anything with a pending state does.
- What if the agent designs it better than I would?
- It often will, on the parts that are pure engineering. The point is not that your design is superior, it is that the decision is recorded and you can explain it later. An undocumented good decision and an undocumented bad one look identical six months on.