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

September 12, 2026AI & Automation8 min read
Two diverging signal paths: one solid line closing on a square, one dashed line fanning into threads

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.

What happens at each aspect-ratio gap gap ≤ 2% match — we say nothing gap ≤ 15% snap, and emit a warning gap > 15% refuse The refusal happens before the paid vendor call, not after it. A warning means we went ahead. A refusal means the gap was too wide to spend on.

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.

TypeScript
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.

Moving the boundary between stored facts and derived prose BEFORE worker rebuilds all warnings column readers A skipped stage rebuilds from an incomplete set, and the earlier write is gone. AFTER worker writes facts preflight facts derive one composer Durable: only what a later write cannot reconstruct. Everything else is computed on read. 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

Dzmitry Vladyka
Dzmitry Vladyka

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 posts