additionalProperties: false, all the way down a UI tree
The flag closes one object's key set. Everyone reads it as a promise about the whole tree, and the most-copied generated-UI schema in circulation is where that misreading gets expensive.

You stamped the flag on every object in the schema, the API stopped returning 400, and the model now emits a component tree that decodes cleanly. That is a tooling problem solved. It is not the same as having closed the contract.
additionalProperties: false on a recursive JSON Schema is a per-object lock read by almost everyone as a per-tree guarantee. It constrains which keys an object may carry. It says nothing about what those keys may contain, and a recursive schema is precisely the shape where that distinction stops being academic — because the thing recursing is the thing you are about to render. This is the layer underneath what a generated screen is structurally unable to do, and it is worth getting right before the tree is load-bearing.
The short version
- The flag closes keys, not values. Each object may carry only the properties you named. Nothing about the flag restricts what those properties hold.
- A closed object can still hold an open map. If you model
attributes: [{name, value}], you have re-opened the object with the schema's blessing, at every depth. - The published recursive UI example does exactly that — and it is the one most teams copy.
- Under strict mode you lose the keywords that would have bounded it. No
maxLength, nopattern, nominItems, nomaximum. Depth limits are what remain, and they differ by vendor. - Swift will not catch the difference.
JSONDecoderignores unknown keys silently, so the client is the most permissive layer in the stack unless you write the rejection yourself.
What additionalProperties: false on a recursive JSON Schema promises, and where it stops
additionalProperties: false is an assertion about one object: no key outside properties is valid here. In strict structured-output modes it is not optional. OpenAI's structured-outputs guide states that it "must always be set in objects" and that all fields must be listed as required (checked 15 August 2026). Microsoft's Azure OpenAI structured outputs page, updated 6 August 2026, repeats both rules. Amazon Bedrock goes further and rejects additionalProperties set to anything other than false outright.
What none of those pages says is what follows from it.
The flag is scoped to the object it sits on. Set it on a node in a recursive schema and you have closed that node's key set at every depth the recursion reaches — which sounds like a whole-tree guarantee and is not one. Consider what a UI tree actually needs to carry: a type, a label, some children, and some way to say the rest. That last requirement is where teams punch a hole through their own contract, and they punch it inside properties, where the flag has no opinion at all.
A schema can be additionalProperties: false at every single node and still accept arbitrary key/value data, because you modelled the escape hatch yourself.The recursive UI tree everyone copies has a hole in it
The canonical example of a recursive schema is a generated UI. It appears in OpenAI's own structured-outputs documentation and is reproduced verbatim in Microsoft's Azure page under the name Dynamically generated UI. Both were checked on 15 August 2026. It looks like the right answer, it validates, and it is where the problem is easiest to see:
{
"type": {
"type": "string",
"enum": ["div", "button", "header", "section", "field", "form"]
},
"label": { "type": "string" },
"children": {
"type": "array",
"items": { "$ref": "#" }
},
"attributes": {
"type": "array",
"description": "Arbitrary attributes for the UI component, suitable for any element",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "value"],
"properties": {
"name": { "type": "string" },
"value": { "type": "string" }
}
}
}
}Every object in that fragment is closed. type is a six-value enum. The tree is nonetheless wide open, because attributes is a string-to-string map with a different spelling, and the schema's own description of it is "Arbitrary attributes for the UI component, suitable for any element." The documentation names onClick and className as the examples.
A model that will not put a "style" key on a node will happily emit {"name": "style", "value": "..."} inside attributes, because that is the shape you asked for. The enum on type constrains which components exist; nothing constrains what they are told to do. If you are designing a generative UI implementation on iOS from this starting point, the flag has bought you tidier decoding and no control.
The test on your own schema is one pass, and it is not "is additionalProperties: false everywhere". It is: for each object, list the properties whose value type is string and whose meaning is decided by another field. Those are your open maps. There is usually exactly one, and it is usually called attributes, props, meta or data.
Under strict mode, recursion costs you the rest of the schema
Recursion makes the escape hatch worse, because strict mode removes the keywords you would otherwise reach for.
Every field is required at every depth. All fields must be listed in required, so a node twelve levels down carries the same full property set as the root. Optionality has to be expressed as a union with null, so every leaf still emits every field.
The bounding keywords are gone. Azure's page publishes the unsupported list under strict mode: minLength, maxLength, pattern and format on strings; minimum, maximum and multipleOf on numbers; minItems, maxItems, uniqueItems and contains on arrays. You cannot cap the label length, constrain the attribute name to a pattern, or limit how many children a node may have. The schema has no way to say "at most six".
Depth is what is left, and the ceiling is not portable. OpenAI's guide allows up to 10 levels of nesting and 5,000 total object properties. Azure's page allows five levels and 100 properties — a fiftyfold difference in the same keyword on the same feature. Bedrock does not support recursive schemas at all. A recursive tree is the one shape where those ceilings are reachable, and therefore the one shape whose behaviour changes when you swap provider.
Swift's decoder does the opposite
The instinct at this point is that the client will catch it. On iOS, the default does the reverse.
JSONDecoder decodes into your CodingKeys and ignores everything else. No error, no warning, no log line. A record carrying six keys you have never heard of decodes into a struct with two properties and reports success. That behaviour is correct for API clients — it is what lets a backend add a field without shipping an app update — and it is exactly wrong when the sender is a model and the payload is a screen.
Strict structured outputs and Swift Codable therefore point in opposite directions, and nothing bridges them for you. If unknown keys must be a failure, you write that:
struct CalloutRecord: Decodable {
let type: String
let tone: Tone
let title: String?
let body: String
private enum CodingKeys: String, CodingKey {
case type, tone, title, body
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Codable gives you no hook for "a key I do not know about", so decode
// the raw object once and compare its key set to the declared one.
let raw = try decoder.singleValueContainer().decode([String: JSONValue].self)
let declared = Set(CodingKeys.allCases.map(\.stringValue))
guard Set(raw.keys).isSubset(of: declared) else {
throw PresentationStreamError.invalidField("callout: unknown key")
}
type = try container.decode(String.self, forKey: .type)
tone = try container.decode(Tone.self, forKey: .tone)
title = try container.decodeIfPresent(String.self, forKey: .title)
body = try container.decode(String.self, forKey: .body)
}
}Unglamorous, and the only version that matches what the schema claims. Write it once per record type, or accept a client more permissive than your contract.
What this still does not protect you from
A closed schema is a shape control, not a security control, and it is worth being precise about the difference.
It does not make the content true. {"type":"callout","tone":"warning","body":"Your account is suspended."} satisfies every constraint discussed here. Schema validity says the record has the right shape, not that anything returned that text.
It does not tell you where a value came from. Closing the key set does not bind a rendered value to a tool result. That is a separate check — grounding — and a schema cannot express it.
It does not survive a repair step. If something downstream patches an invalid record into a valid one, the contract is decorative. Rejecting rather than patching is what makes the schema mean anything.
It is not an allowlist for behaviour. An enum of component types constrains which components can appear. What each one does when tapped is a different problem, and it does not belong in the tree at all.
How Uzori does it: no recursion at all
The design conclusion Uzori reached is that a generated screen does not need to be a recursive tree, and that giving it one imports every problem above for a nesting depth nobody uses.
The wire is 23 flat record types under a root oneOf, streamed one per line. Every definition is additionalProperties: false, and there is no children, no attributes, and no colour, font or spacing field anywhere in the schema — style is resolved on the device and is never serialized to the server. The callout record is the whole record:
{
"type": "object",
"additionalProperties": false,
"required": ["type", "tone", "body"],
"properties": {
"type": { "const": "callout" },
"tone": { "enum": ["neutral", "information", "success", "warning"] },
"title": { "type": "string", "minLength": 1, "maxLength": 120 },
"body": { "type": "string", "minLength": 1, "maxLength": 600 }
}
}Structure that a tree would express through nesting is expressed instead as a state machine over the record stream. A comparison opens with comparison_start, accepts subjects and then rows, and closes with comparison_end; the grammar declares "groupsMayNest": false, and no state in its transition table admits a group-start record while a group is open. Depth is therefore something the server tracks rather than something the model authors, and the client re-implements the same grammar independently.
Frequently asked questions
Do I have to set additionalProperties: false on every nested object?
Yes, in every strict structured-output mode currently shipping. OpenAI's guide requires it in all objects, Azure's page repeats the rule, and Bedrock rejects any other value. Nested definitions under $defs are objects too, which is the usual cause of a 400 — the flag lands on the root and not on the definitions the root references.
Does additionalProperties: false work with recursive schemas?
It applies at each node the recursion reaches, which is the correct behaviour and a weaker guarantee than it sounds. Recursion support itself varies: OpenAI supports "$ref": "#" and #/$defs/..., Azure documents both forms, and Bedrock lists recursive schemas as unsupported. Check the provider before designing a tree that depends on it.
What happens if the model tries to add a field anyway?
Under constrained decoding it generally cannot — the grammar compiled from your schema does not admit the token. That guarantee covers the provider's own inference path only. Anything you validate yourself, or receive through another route, still needs a real validator, and it should reject the record rather than strip the offending key.
Should a generated UI schema be recursive at all?
Usually not. Recursion buys arbitrary depth, which almost no product surface needs, and costs you portability, the bounding keywords strict mode removes, and a clear place to enforce cardinality. A flat catalogue of closed record types with an explicit grammar gives you the same screens and a contract you can check in one pass.
Where to start
Open your schema and do the one-pass audit: for every object, find the properties whose meaning is decided by a sibling field rather than by the schema. That list, not the count of additionalProperties: false lines, is what the model can actually author.
If the answer is "more than I expected", the alternative is a flat record catalogue with a stream grammar instead of a tree. The shipped schema and grammar are the first thing we would show you — email hello@uzori.ai and ask for them.