Validate it twice: client-side validation of LLM output on iOS
Two validators are only worth having if they can disagree. Here is the shipped proof that these two can — they have already drifted apart on two limits, in opposite directions.

The server already checked it. Every line passed a schema, a state machine, a grounding check and a cardinality check, and only then was it forwarded. So when the bytes reach the device, what exactly is the point of checking them again?
The case for client-side validation of LLM output on iOS is not that the server might be lying. It is that the server might be wrong. A bad deploy, a loosened constant, a regression in a check nobody exercises: the device is the last place where being wrong is still cheap. That argument is only true if the two validators can actually disagree, which is a property most implementations claim and few can demonstrate.
The short version
Validate on the server so bad output never reaches the device, and validate again on the device so a server bug does not become a rendering bug. The two must be independent implementations of the same written spec, not one implementation shipped twice. Where their limits differ, the tighter one holds. Rejection must leave the previous screen untouched.
The objection, which is correct and about something else
The standing objection to client-side validation is that it is evadable: code running on a device you do not control can be patched out, so anything you enforce there is advisory. That is right, and it is a statement about a different threat model: the one where the user is the adversary and the server is the thing being protected.
Invert it. Here the user is who you are protecting, and the thing that might be wrong is the server. A user cannot benefit from disabling the check that stops their own app rendering a malformed screen, so the incentive that makes client-side enforcement futile is simply absent. Every server-side control still applies unchanged; this is an additional layer, never a substitute.
The empirical case for layering is available and it happens to come from iOS. "Mind your key: An Empirical Study of LLM API Credential Leakage in iOS Apps" (arXiv:2606.12212v2, 20 June 2026) studied 444 apps, checked again on 20 August 2026. It found 282 of them exposing exploitable LLM API credentials in network traffic. The finding that transfers here is about defences rather than leaks: multi-mechanism deployments showed a 7% bypass rate against 55% for single-mechanism approaches. Different layer, same shape of result. One control is a control; two controls are a system.
Independence is the whole property
A client that fetches the server's rules and applies them is not a second validator. It is the same validator with two deploy targets, and it fails in exactly the ways the first one does. If a constant is wrong in the rule set, it is wrong in both places at once.
Independence means the client is written against the specification — the record catalogue and the stream grammar as documents — rather than against the server's implementation of it. Two people, two languages, one spec. What that buys is narrow and worth stating precisely:
A server bug that loosens a check. Someone edits a limit during a debugging session and it survives review. The gate now forwards records it should not, and the client refuses them.
A record type that means something different on each side. The server's grammar and the client's reducer disagree about which state a record is legal in. One of them is wrong, and the disagreement surfaces as a rejected record instead of a wrong screen.
What it does not buy is protection from a wrong spec. Two faithful implementations of the same bad rule agree perfectly.
Proof: the two implementations have already diverged
This is the part you can check rather than believe. Comparing the server's stream grammar against the iOS reducer's own guards, on the shipped tree as of 20 August 2026:
Collections per screen — server 3, client 3. Equal.
Collection items per collection — server 20, client 40. The server is tighter, so 20 is what holds. A screen can never contain a 21-item collection, and if the server's limit were raised to 45 tomorrow, 40 would start holding instead.
Comparisons per screen — server 1, client 1. Equal.
Comparison subjects — server 3, client 3. Equal.
Comparison rows — server 20, client 8. The client is tighter, so 8 is what holds. A server that forwards a 9-row comparison gets a rejected record on the device, and the user keeps the screen they had.
Steps blocks — server 2, client 2. Step items — server 12, client 12. Locations blocks — server 1, client 1. Location items — server 20, client 20. Media blocks — server 2, client 2. Media items — server 10, client 10. All equal.
Two divergences out of eleven, pointing in opposite directions. That is the evidence: a copy of the server's rules could not disagree with them, and could not disagree in one direction here and the other direction there.
Why divergence is safe rather than alarming
The instinct on seeing two numbers that should match is to file a bug. Resist it, because the shape of the constraint makes drift harmless.
Both sides express ceilings. The effective limit is therefore the minimum of the two, automatically, with no coordination — and neither side can loosen the other. A server that raises a cap cannot make the client accept more; a client that raises one cannot make the gate forward more. The worst outcome of drift is a limit tighter than either team intended, which surfaces as a rejected record in testing rather than as a bad screen in production.
That property is why the client also re-checks constraints the wire schema already states. Narrative paragraphs are capped at three, bullets at six, facts items at eight, metrics items at two to four, tags at two and required to be unique — all of them present in the schema as maxItems, all of them written again as Swift guards. Re-stating them costs a few lines and removes the assumption that a schema validator ran, correctly, on the version of the schema you think is deployed.
What the client checks that the server cannot
Some rules are not the server's to enforce, because it does not hold the facts.
Which actions this app was configured with. The wire carries only an actionRef. The server rejects refs outside the set the integration declared, but the set the installed app registered is a client-side fact, so a ref the app was not configured with is dropped on the device and does not even receive a button trait. It is not tappable and not announced as tappable. That is one of the constraints in what a generated screen is structurally unable to do, and only the client can apply it.
Whether the screen is finished. Draft content renders and scrolls but does not respond to taps until the terminal record has arrived and the turn has ended. Interactivity is a UI state, and the server has no view of it.
Rejection has to be free, or you will not do it
A second validator you are afraid to trigger is decoration. The reason the client can afford to be strict is that rejecting a record costs nothing, and in Swift that falls out of the type system:
var next = reducer // struct: this is a copy
try next.apply(frame) // mutates the copy, or throws
reducer = next // never reached if apply threwIf apply throws, the copy is discarded and the live reducer still holds the state from before the record arrived. There is no rollback to write and no half-applied mutation to unwind. That makes strictness cheap, which is what keeps it in the codebase — and it is the same principle as rejecting invalid model output rather than repairing it, applied one layer further down.
What this still does not protect you from
Two right implementations of a wrong spec. If the grammar permits a screen that should not exist, both validators will permit it, twice, confidently.
Old app versions. The client half ships in a binary. Tightening a client-side limit reaches users over weeks, and some never update. The server is the only layer you can fix this afternoon, which is why it has to be the primary one and the client the backstop, never the reverse.
A hostile user. Nothing here defends the server from someone running a patched build. Authentication, authorisation and rate limiting are unchanged and still server-side problems.
And the ongoing cost is real. Every protocol change is two edits in two languages, plus a release for the second. If your record catalogue is still changing weekly, that tax is heavy and honestly earned. The property is worth buying once the wire has settled, and premature if it has not.
Frequently asked questions
Isn't client-side validation pointless if the server already checks?
It is pointless as a replacement and useful as a second layer, because the two fail differently. A server-side bug, such as a loosened constant or a regression in a check with no test, passes the gate and is caught on the device. The condition for that to work is independence: the client has to implement the spec, not download the server's rules.
Can you trust validation that runs on the device?
Not against a user who controls the device, which is the usual objection and a fair one. That threat model does not apply when the client check exists to protect the user from a server mistake — nobody benefits from patching out the code that stops their own app rendering a broken screen. Keep every server-side control regardless.
What happens when the two validators disagree?
The tighter one wins, and nothing else needs to happen. Because both express ceilings, the effective limit is the minimum automatically, so a disagreement produces a rejected record rather than an inconsistent screen. Log it — a disagreement is a real signal about drift — but it is not an outage.
How do you keep two validators in sync?
You mostly do not, deliberately. Keep the spec as a checked-in data file that both are written against, test both against the same corpus of good and bad records, and accept that limits will drift. Forcing sync by generating one from the other reintroduces the single point of failure that having two was meant to remove.
Where to start
Run one experiment against your own stack. Loosen a limit on the server — raise a cardinality cap by one — and send a record that exceeds the original. If it renders, you have one validator. If the device refuses it, you have two, and you now know which of them is tighter.
Then repeat it in the other direction, because the interesting case is a client that is stricter than the server and nobody remembers why.
If you would rather read the version that already does this, the shipped stream grammar and the iOS reducer are what we would show you first, including the two limits where they disagree. Email hello@uzori.ai and ask for them.