Why coding agents drop constraints at the plan step
Day 6 — The plan is where your spec quietly dies
In the spec→plan→tasks→code pipeline, the plan is the step that leaks. A coding agent hands you a numbered, confident, plausible-looking plan that quietly dropped the constraints your spec actually leaned on — and you won't find out until production, because the code it produces compiles and passes its own tests. This lesson is about treating the plan as a contract you can reject, not a formality you skim.
The cold open: 12 clean tasks, all green, then corruption
A wallet feature. The spec was short and sane: transfer an amount from wallet A to wallet B, both balances update, the ledger records the move. I fed it to the agent and got a plan back — 12 tasks, cleanly numbered, each with a clear name: 'debit source wallet', 'credit destination wallet', 'write ledger entry', 'return updated balances', and so on. It read like something a good engineer would write. I approved it. The tasks turned into code. Everything compiled. Every unit test the agent wrote was green — debit works, credit works, ledger writes, balances return correctly.
We shipped. Under real concurrent load, balances started drifting. Money debited from A that never landed in B. A ledger whose entries didn't sum. The kind of bug that makes finance people stop smiling.
The autopsy: nobody dropped the ball, the plan did
The spec said 'transfer'. To any engineer, 'transfer' *implies* atomicity — debit and credit are one unit, all or nothing. That word was load-bearing. But the plan never wrote 'transaction boundary'. It listed debit as task 4 and credit as task 6, two independent, tidy steps. The agent optimized for a plan that reads coherently, and two separate labeled steps read *more* coherent than one messy 'wrap these two in a transaction and handle rollback on failure' step.
Here's the trap: the missing atomicity was invisible at every downstream gate. The code compiled — nothing about a missing transaction is a type error. The unit tests passed — each function does its own job perfectly in isolation, and unit tests don't run two transfers in the same millisecond. Green tests didn't make it safer. They made it *worse*, because green is what let me stop looking. The loss happened at the plan step, and every gate after it was structurally blind to it.
Why this is structural, not a fluke
Think of the plan as a lossy compression of the spec. The spec carries N constraints. The plan is a shorter artifact that has to represent them. Something gets dropped in compression — that's what lossy means. The question is *what* gets dropped, and it isn't random. An LLM generating a plan optimizes for one thing: a plan that looks complete and coherent. It is not optimizing for 'preserves every constraint in the source'. Those two goals overlap a lot, which is exactly what makes it dangerous — they overlap enough that the plan looks right.
The first constraints to vanish are the ones that don't map to a visible feature. A user-facing capability — 'show the balance', 'send the receipt email' — survives compression, because it maps to an obvious task with an obvious name. But a transaction boundary isn't a feature; it's a *seam* between two features. Idempotency isn't a screen; it's a property of a screen under retry. These seams are precisely the constraints with no natural home in a feature-shaped plan, so they're the ones that fall out. The agent isn't being lazy. It's compressing, and seams compress away first.
The seams that get silently dropped — your hunt list
This is the practical payload. When you review any agent-generated plan, don't read it top to bottom nodding along. Actively hunt for these six, because they're the ones a coherent-looking plan omits. Each is a place where the plan says nothing and the gap stays invisible downstream.
- ▹Transaction boundaries — which operations must succeed or fail as one unit? A plan that lists steps 4 and 6 separately has already lost this. Ask: what happens if step 4 commits and step 6 crashes?
- ▹Idempotency keys — the client will retry. The network will double-send. The agent's plan almost never says 'this operation must be safe to run twice'. If the plan has a 'create charge' step with no idempotency key, you get double charges.
- ▹Auth / context propagation — who is the caller, and does that identity survive across the internal hops? Plans love to describe the happy path where the user is implicitly present, and drop the part where a background job or a second service has no user context.
- ▹Error paths — the plan describes what happens when everything works. Ask where it says what happens on partial failure, timeout, or a downstream 500. Usually: nowhere.
- ▹Migration order — schema change and the code that depends on it. A plan that says 'add column' and 'read column' without ordering, or without a backfill/deploy sequence, will break on rollout even though every task is individually correct.
- ▹Concurrency assumptions — what does this code assume about being the only one running? Two requests, same row, same instant. If the plan never names the assumption, the code inherits whatever the agent felt like, which is usually 'single-threaded and alone'.
The fix: review the plan as a contract you can reject
The discipline of forward engineering isn't writing a beautiful spec. It's treating the plan as a rejectable contract and reviewing it *harder* than you review code. The core move is a traceability check: walk every clause of the spec and find its home in the plan. Each spec constraint must map to at least one plan item. A clause with no home in the plan is a dropped constraint — and you just caught it at review, for free, instead of in prod at 2am.
Do it literally. List spec clauses on the left, plan items on the right, draw the lines. The unmatched clauses on the left are your bugs before they exist.
SPEC CLAUSE → PLAN ITEM(S) STATUS
----------------------------------------------------------------------
"transfer amount A→B" → task 4 (debit) PARTIAL
task 6 (credit)
⚠ no atomic boundary DROPPED
"both balances update" → task 5, task 7 OK
"ledger records the move" → task 8 OK
"safe under concurrent transfers" → (nothing) DROPPED
"safe to retry (client timeout)" → (nothing) DROPPED
----------------------------------------------------------------------
VERDICT: REJECT. 2 clauses have no home in the plan,
1 clause is split across steps with no atomicity guarantee.And rejecting is a real, concrete act — not vibes. It sounds like this, handed straight back to the agent:
REJECTED. The plan splits debit (task 4) and credit (task 6)
into independent steps. The spec word "transfer" requires these
to be atomic — all-or-nothing.
Revise the plan to:
1. Wrap debit + credit + ledger write in one transaction;
define the rollback behavior on any failure inside it.
2. Add an idempotency key to the transfer entry point so a
client retry cannot double-apply.
3. Name the concurrency assumption explicitly: row-level lock
on both wallet rows, consistent lock ordering to avoid
deadlock.
Do not write code until the plan names these three seams.Notice what this costs: a few minutes of reading and one paragraph typed back. Notice what it saves: the corruption incident, the postmortem, the trust you burn with the finance team. That asymmetry is the whole argument.
The mindset flip: review the plan harder than the code
Here's the flip that reorganizes how you spend attention across the whole pipeline. Bad code fails loudly — it throws, it red-tests, it won't compile, the linter screams. The failure comes to you. A bad plan fails *invisibly* — it produces code that compiles and passes, and the failure hides until conditions in production expose it. So your review effort should be inversely proportional to how loud the failure is. Spend your scrutiny where the failure is silent. That means the plan gets your hardest, most adversarial read — harder than the code review, because the code review has compilers and tests as backup, and the plan review has only you.
This also sets up Day 7. Once you've got a plan that survives the traceability check — every clause has a home, every seam is named — the next job is turning that vetted plan into tasks where each task carries its own falsifiable exit condition. Not 'debit the wallet' but 'debit the wallet; PROVEN when a concurrent double-transfer test shows no balance drift'. A vetted plan is the raw material; tomorrow we make each task carry its own proof.
Extend your knowledge
- ▹Take a plan your coding agent generated this week and run the traceability table on it by hand: spec clauses left, plan items right, draw the lines. Count the orphaned clauses — that number is your calibration for how much the plan step is leaking.
- ▹Build a reusable review prompt: after the agent produces a plan, feed it back with 'For each of these six seams — transaction boundaries, idempotency, auth propagation, error paths, migration order, concurrency — state where in the plan it's addressed, or write DROPPED.' Make the agent audit its own compression.
- ▹Read the original Spec-Driven Development framing (GitHub's Spec Kit and the spec→plan→tasks decomposition it popularized) and notice how much weight the workflow puts on the plan artifact specifically — then ask why the plan is where teams still get burned.
- ▹Look up the 'idempotency key' pattern in Stripe's API docs as a concrete reference for what naming a seam explicitly looks like — it's the difference between a plan that says 'create charge' and one that says 'create charge, safe under retry'.
Discussion
Chat with Chi Cong (AI) about this article. Your conversation is private to you — you can publish a summary for others when you're done.