Your Agent Can Branch on a Refusal. It Cannot Branch on a Warning.

We shipped an API that agents call. Refusals carry stable codes: account_not_admitted,
debt_outstanding, actor_format_unavailable. Warnings carry none. They are an array of
strings, and the strings change when behaviour changes.
That asymmetry was not an oversight, and it took us a while to be able to say why.
The rule we started with
One project rule governs every unmet parameter: it gets named out loud, never dropped in silence. If a caller asks for something the renderer will not do, the response says so.
This is easy to agree with and easy to implement badly. The obvious implementation is a warning code — machine-readable, stable, documented. An agent reads the code, branches, retries with different parameters. Clean.
We do not do that, and the reason is that a warning and a refusal answer different questions.
A refusal answers did this happen. There is one correct behaviour on each code, the
caller can encode it once, and it stays correct. debt_outstanding means settle the debt.
account_not_admitted means the account cannot render yet. An agent can branch on that
without understanding it.
A warning answers what exactly did we do instead. Here is one, as a caller receives it:
source is 941×1500; rendering 9:16 is taller than the source, so the vendor
center-crops the sides — we do not crop it ourselves
Notice the second half. The point of that sentence is to separate the vendor's framing from ours. There is no code for "the vendor cropped it and we want you to know it was not us", and inventing one would be a code whose only correct handling is read the prose.
So the honest contract is: refusals are for agents, warnings are for the human who will eventually ask why the video looks like that.
What warnings do not mean
Three things sound natural here and are false. We know because we wrote at least one of them into our own docs.
We do not warn instead of refusing. Aspect-ratio handling has three bands, not two.
The middle band is the only one that produces a warning. The band above it produces a refusal, and it happens before any money is spent.
"Rather than a hard failure, we tell you about the mismatch" is a sentence that reverses the money rule: the warning is for work we go ahead and do, and the refusal is for when the gap is too wide to spend a customer's credits on. That sentence is ours. It shipped in our own docs, and it is the one of the three we wrote down before we caught it.
Warning strings are not stable. They live in code next to the behaviour they describe, and a story that changes the behaviour changes the string. A client parsing them by substring breaks on our next commit. We do not promise otherwise, which is the whole reason refusals have codes and warnings do not.
quote does not always say what the run will say. A raw voice_id cannot be verified
at quote time — there is no vendor account context yet. So one of our warnings is literally
about when the check happened:
raw voice_id is verified against your account at run start, not at quote time
Where the two surfaces agree, it is because a shared resolver computes both. Where they disagree, it is because they know different things. Parity by architecture, not parity by hope.
The bug that taught us where warnings live
The rule was in place for months. It still lost warnings, twice, in a way no test caught.
Some of our warnings are a pure function of the stored request. The disposition registry, which parameters do not reach the renderer yet, is derivable. So is the preset-voice language note. Given the request, you can recompute those at any time.
Others cannot be recomputed and have to be stored: whether an audio track was recovered from an earlier paid generation, how the price was arrived at, whether an AI disclosure made it into the file.
The two that broke were not just non-derivable. They were non-derivable and produced by
a stage that can be skipped. Whether we managed to measure the source image is a network
call. Whether the vendor's voice catalogue was complete when we checked a raw voice_id
is a fact about the vendor at that moment. Neither property alone loses a warning. Together
they do.
And the worker rebuilt the entire warnings column on every stage transition, from whatever it held in memory. On a checkpoint hit, and on recovering an audio track from an earlier paid generation, the stage returns before it reaches preflight — so the set in memory never contained the preflight warning, and the rebuilt array no longer carried what the previous write had known.
No race. No concurrency bug. Just a later write that did not know what an earlier write knew, overwriting it with a confident, complete-looking array.
The fix was to move the boundary, not to add a guard
We stopped storing warnings and started storing the facts that cannot be recomputed: a small tagged union(opens in new tab), one family per probe.
1aspect: not_probed | failed{error_class} | ok{width, height}
2voice: not_probed | catalog_incomplete | verified
The families are parsed independently, so corruption in one cannot swallow the other.
Then the string gets exactly one producer, called by both readers of a run. A rebuild cannot lose a string it no longer owns.
The column stopped being the answer and became the evidence.
Three details are the actual engineering, and all of them are about making the compiler hold the rule:
The composer's third argument, the stored facts, is required, not optional. An optional parameter would let a new reader forget it and silently emit a thinner set of warnings. The demanding signature is the guard.
And the aspect ratio is resolved by the same function that quote uses. The two surfaces
match structurally rather than because two lists happen to agree today.
That last distinction is not academic. On the selected-actor path, parity was held by data and not by code: all thirty catalogue entries sit within 0.03% of their label, deep inside the 2% band, so the warning never fired and nobody noticed there was nothing forcing it to. The first entry to drift would have produced a silent substitution. A guard for that landed separately, after the fact.
What to take from it
If you are designing an API that agents call:
Give refusals codes. They are control flow. One correct response per code, encodable once, stable across releases.
Do not give warnings codes you cannot keep. A code implies a contract. If the only correct handling of your warning is for a person to read it, say that plainly instead of shipping a stable-looking identifier that is not.
Store what you cannot recompute. Derive the rest at read time. Our warnings column looked like a source of truth — readers served it as the answer — and behaved like a cache, rebuilt wholesale on every stage transition. That is how a rebuild could quietly overwrite the only copy of a network fact. The facts are durable now; the prose is computed.
Check whether your parity is structural or empirical. "These two surfaces agree" is a much weaker claim when what enforces it is the current contents of a table. It is the same reason multi-step agents compound failure: each step looks correct in isolation, and nothing checks the invariant across them.
This is the same family of defect as an agent reporting success it never achieved and a schema that offers a field it will reject: the system is confident, the response is well-formed, and the thing that went missing leaves no trace.
FAQ
Should warnings ever have stable codes?
Only if you can keep them stable. A code is a contract, and the contract is that a caller can branch on it forever. If your warning text exists to explain a judgement call that will change as the system changes, a code freezes a decision you have not actually frozen.
How do you tell a warning from a refusal?
Ask whether there is exactly one correct response. If the caller should always do the same thing, it is a refusal and it deserves a code. If the correct response depends on what the caller was trying to achieve, it is a warning and it deserves a sentence.
What should be stored versus derived?
Store only what a later write cannot reconstruct — typically the results of network calls and facts about external state at a moment in time. Derive everything that is a function of the stored request. A column holding both is a column where a rebuild can silently drop the half that mattered.
Your agent asked for 9:16 and got something else.
An unmet parameter should be named, not silently swapped. That is the contract behind Clipwright — and a gap too wide to render is refused before the vendor is paid, not after.
About the Author
Dimantika
Founder of Dimantika. Co-founded and exited a SaaS at $1.2M ARR. Now building AI tools for founders who want autonomous growth without blind trust in agents.
View all postsRelated posts
More articles you might like.

Your Container Lies About Its Size, and Your Library Believes It
Our render container reported 32 cores when it had 4. Remotion sized its defaults from that and OOMed. Five confident diagnoses died before the answer turned up in a version field nobody had thought to print.

We Charged 900 Credits for a Video That Didn't Exist
Our billing rule said: failed run after a paid vendor call means we charge. It sounded fair. On a run that only paid for speech, it was false, and it cost a customer.

Your Agent Isn't Confused. Your Schema Lied to It.
Teams blame MCP when agents fumble their tools. We shipped an MCP server for a video API and found the real defect: offering a field you answer with a 400.