Reject, never repair: a patched UI is a UI you did not design
Every guide on this treats model output as data flowing into a program. When the output is the screen itself, a heuristic guessing at a missing bracket is authoring your product.

You already have the validator. It parses each line the model emits, checks the shape against a schema, and throws when the check fails. What is still open is the catch branch, and that is where reject vs repair for invalid LLM output stops being a library question and becomes a decision about what your app is allowed to put in front of a customer.
Every piece of writing on this term settles it the same way: it depends on your use case. Guardrails AI ships eight failure actions in one enum and documents each of them neutrally. The json_repair package has thousands of stars and a parser that will happily close your brackets. Both are right for the case they were built for — model output flowing into a program. Neither is written for output that is the screen. Once the model is composing what a generated screen actually is on iOS, these are not two settings. One of them renders an interface nobody authored.
The short version
- Validate every record against a fixed schema before it reaches the device.
- On failure, drop the record and end the turn. Do not patch, coerce, complete or default.
- Repairing syntax always means deciding content, because the repairer has no idea what the content meant.
- Under a streaming protocol the option barely exists: records ahead of the failure are already on the device.
- A bounded re-ask is reasonable — once, before anything has been forwarded. After that, fail closed and say so.
Reject vs repair for invalid LLM output is not a config toggle
The vocabulary comes from Guardrails AI, and the vocabulary is the problem. Its validator on-fail actions page, checked 6 August 2026, documents eight values: REASK, FIX, FILTER, REFRAIN, NOOP, EXCEPTION, FIX_REASK and CUSTOM. FIX is described as "programmatically fix the generated output to meet the correctness criteria when possible". REFRAIN is "refrain from returning an output".
Eight sibling values in one enum, each with one line of reference prose, none recommended over another. That layout carries an argument it never states: these are alternatives of the same kind, and picking between them is a matter of taste. For a summarizer or a classifier, fair enough. For a tree that becomes pixels, FIX and REFRAIN are not siblings at all. One of them produces output whose author is a heuristic.
A flag with eight equal-looking values is a design that tells you the choice does not matter much. For rendered output, it is the only choice that does.
Why the obvious approach breaks
Repair looks like a syntax operation. It is sold as one: unbalanced quotes, a trailing comma, an unterminated array. The json_repair README, checked 6 August 2026, lists exactly that — missing quotes, misplaced commas, unescaped characters, incomplete key-value pairs — and then one more capability, which is where the frame collapses: it "fills missing values with reasonable defaults". The same README warns that pushing already-valid JSON through the repair path "can change the resulting structure or values".
That warning is the whole argument, published by the repair library itself. Closing a bracket is syntax. Choosing which bracket to close is content.
The three things a repairer has to invent
Take a real record type. A callout in a read-only UI contract requires a type, a tone and a body, where tone is an enum of four values, and the definition sets additionalProperties: false. Now the model emits "tone": "danger".
There is no syntactic repair for that. The parser has to pick one of three inventions:
- Substitute a neighbour.
dangerbecomeswarning, because they are near each other in meaning. You have just decided how alarmed a customer should be about their own account. - Drop the field. But
toneis required, so dropping it means substituting the schema default instead — the same decision, made silently. - Drop the record. Which is rejection, arrived at by a longer route, after the repairer has already searched for a way not to.
None of those is a fix. Each is an edit to a message a human will read, made by code that cannot read it. And the failure mode is the quiet one: the screen renders, looks fine, and is wrong in a way no exception ever reported.
What a patched tree costs that a patched row does not
Every article on page one for this query imagines the same downstream: a queue, a CRM, a workflow engine, a function argument. In that world a repaired value is a data-quality bug. It is logged, it is discovered later, it is fixed in a migration.
A UI tree has a different downstream. It is looked at, by a person, inside your product, wearing your brand. And it carries an implicit claim that some human signed off on it — that a designer chose the hierarchy, that an engineer chose the component, that someone in review decided this is a thing the company says.
A repaired tree breaks that claim silently. Ask who authored the screen and there is no answer. Not the designer, who never saw this arrangement. Not the model, whose output was overwritten. Not the engineer, who wrote a general-purpose parser, not this screen. The accessibility review that covered your components did not cover a component the repairer defaulted into existence. This is the part the tooling conversation never reaches, and it is the reason the neutral framing does not survive contact with rendered output.
Streaming removes most of the option anyway
Here is the mechanical argument, and it is missing from every result on this SERP: repair is a function from a complete broken document to a complete fixed one. Streaming does not give you a complete document.
If your protocol ships one JSON object per line and the client renders each one as it lands, then by the time line seven fails, lines one through six are already on the device and already drawn. There is nothing to repair — there is only a decision about line seven, and it has to be made before that line is forwarded. Not after. Not in a post-processing pass over the finished output.
{"type":"screen_start","screenId":"s_7f3","presentation":"summary"} → forwarded
{"type":"callout","tone":"warning","body":"Your plan changed on 14 July."} → forwarded
{"type":"metrics","items":[{"label":"This month","value":"£64.20"}]} → forwarded
{"type":"callout","tone":"danger","body":"..."} → rejected, turn abortedThat constraint is a gift. It forces the decision to the only place it can be made correctly — per record, before anything is committed — and it removes the temptation to salvage a turn that has already gone wrong. If you want the rest of the argument for why per-record validation beats document-level validation, the rest of this series works through the streaming protocol in more detail.
What to do instead: fail closed, and say so
Fail-closed validation is not "throw and hope". It is a specific sequence:
- Validate before forwarding, never after. A record that reaches the client is a record you have endorsed. Order the checks so the cheapest and most decisive run first.
- Reject the whole record, not the offending field. Field-level rescue is repair with better manners.
- End the turn deterministically. Emit an abort marker, tear down whatever was drawn, and restore the screen the user was on before the turn started. A half-built screen left on the device is worse than no screen.
- Allow exactly one bounded re-ask, and only before anything has been forwarded. Once a record is on the device the turn is committed and a second attempt would contradict what the user already sees.
- Log the original bytes. Not your parsed object, not a normalised copy — the exact line that failed, with the failing check named. It is the only artifact that tells you whether the prompt, the schema or the model moved.
- Tell the user something true. "I could not build that screen" is a better product than a screen with a defaulted tone.
What this still does not protect you from
Rejecting invalid output buys you exactly one guarantee: nothing renders unless it is well-formed against your contract. That is narrower than it sounds, and pretending otherwise is how this argument gets discredited.
A record can pass every structural check and still be wrong. Correct schema, correct grammar state, wrong number — validation has nothing to say about it. Grounding checks help, because they tie every value in the tree back to something an API actually returned, but they bound the failure rather than eliminate it.
There is also a real cost, and it lands on the user. A patched screen shows something; a rejected turn shows an apology. On the turns that fail, fail-closed validation is the worse experience in the moment.
The trade is deliberate: a rare honest failure instead of an occasional confident lie. If your product genuinely prefers the second, this whole position is the wrong one for you.
How Uzori enforces it
Uzori's presentation gate runs six checks on every line, in fixed order: JSON Schema, screen identity, navigation match, stream grammar state, grounding, then cardinality. A line that passes all six is forwarded as the original bytes — not re-serialised from a parsed object — so what renders is byte-identical to what was validated. A line that fails any of them throws, and there is no branch in that code that assigns a value.
The schema is what makes repair impossible rather than merely discouraged. There are 23 record types, every definition closes with additionalProperties: false, and nowhere in the contract is there a field for colour, font, spacing or nesting.
{
"type": "object",
"additionalProperties": false,
"required": ["type", "tone", "body"],
"properties": {
"type": { "const": "callout" },
"tone": { "enum": ["neutral", "information", "success", "warning"] },
"body": { "type": "string", "minLength": 1, "maxLength": 600 }
}
}The server never repairs model output. One bounded re-ask is permitted only when the first record fails and nothing has been forwarded; once the stream has started, a failure emits screen_abort and the pre-turn screen comes back. The iOS reducer then re-implements the same grammar and its own cardinality limits independently, so the tighter of the two wins even if the server is wrong. That is the shape of an in-app assistant that answers in native screens: a contract with no field a repairer could fill in, and a gate with no branch that would fill one.
Frequently asked questions
Should you auto-repair LLM JSON or fail?
Auto-repair is defensible when the output is consumed by code that will validate it again downstream, and the cost of a bad value is a retryable job. It is not defensible when the output is rendered to a person, because every repair substitutes a value no human chose. Fail closed, log the original bytes, and re-ask once before anything has been forwarded.
What does OnFailAction.FIX actually do?
In Guardrails AI, FIX applies a programmatic correction so the output meets the validator's criteria, where such a correction exists. The docs, checked 6 August 2026, describe it neutrally alongside REFRAIN, EXCEPTION, REASK and four others. The mechanism is per-validator: what counts as a fix is whatever that validator's author implemented, which is worth reading before enabling it.
Is rejecting the same as retrying?
No. Rejection is what you do with the bad record: drop it, never render it. A retry is a separate decision about whether to ask the model again. They are often conflated because libraries expose both behind one flag. Rejection is unconditional; a re-ask should be bounded, and only safe before any output has reached the client.
Does constrained decoding make this argument moot?
It narrows the failure surface without closing it. Constrained decoding and strict structured-output modes make malformed JSON rare, but they enforce the schema, not the semantics — a response can satisfy every type constraint and still reference an identifier that does not exist, or exceed a limit the schema never expressed. Server-side validation stays load-bearing.
Where to start
Open the failure branch in your own pipeline and read it honestly. If any path in there assigns a value the model did not emit, you are shipping screens with no author, and the only reason it has not caused an incident yet is that nobody has looked.
The cheapest fix is the boring one: make rejection the only outcome, make the abort deterministic, and put the original failing bytes in your logs. If you would rather start from a contract built so that repair has nothing to repair, see what Uzori validates before a screen reaches the device.