ADR-0076: The form schema, an allow-list compiled to Pydantic¶
Status: Accepted Date: 2026-10-17 Deciders: Alex Nodeland
Context¶
lattice RFC-0005 (O4) chose a JSON Schema 2020-12 subset with separate UI hints, validated in Python with no third-party validator, by compiling a FormSchema to a Pydantic model once. Models write the schema, so a refusal must say what to change; portalr renders each error under its field.
Decision¶
FormSchemais a Pydantic model of the subset, strict and closed. A keyword outside it is an extra input, refused at its place. The keywords a model is most likely to write,$ref,$defs,pattern,if,then,else,allOf,anyOfandnot, are refused by name before anything else is read (REFUSED_KEYWORDS), as are a nested object andformat: "password", with reasons of their own.- A property is told apart by its
type, or as choices byenumoroneOf, whosetypeis implied.integeris anumberproperty with whole values. An array holds choices only. - A
defaultmust be a value its property takes, so portalr never prefills an invalid value, and bounds no value meets are refused: aminLengthover itsmaxLength, a lower bound over an upper one (or equal, with either exclusive), aminItemsover itsmaxItems, or over the number of choices when each is chosen once. - A property's name is an identifier: a letter or
_, then up to 63 letters, digits,_or-, and never__proto__,constructororprototype, which JavaScript objects treat apart. Any such name is a field, whatever Python makes of it. - Each property names the type its values are validated as, and the schema compiles them into a strict model, once for each schema, kept by its JSON for the 256 schemas used last, whose fields have names of their own and the properties' names as aliases. Any property name is a field, and an error's
locis[name]; values that aren't an object, or are over the limit, are refused at[]. The values are returned as given. - A string is at most 10,000 characters whatever its
maxLengthsays. - A schema is stored as JSON Schema, with what was left out still left out and
type,requiredandadditionalProperties: falsewritten, and reads back unchanged. The 32 KB limit is a form's whole, its title, schema and hints, measured as stored, so a form that passed once passes every read; a title is at most 200 characters. - The refusal's message names every error, with Pydantic's errors as its
errors; the agent's tools give it to the model as the retry. A refused schema'slocis Pydantic's, with the property's kind after its name, as Pydantic tags a union's member. UiHintsholdfields(awidget,placeholderandhelpper property),order,sectionsandsubmit_label, and nothing else; a hint must name a property of the schema, and a widget must fit it (textareaa string,selectchoices,checkboxa boolean).
Options considered¶
| Option | For | Against |
|---|---|---|
| Pydantic models of the subset, compiled per schema (chosen) | No new dependency; Pydantic's errors carry loc; the wire schema comes from the same models |
Pydantic's tags appear in a refused schema's loc |
| A walk over the raw JSON | Errors exactly as we want them | A validator of our own to keep, beside the TypeScript one |
jsonschema |
Full JSON Schema | A third-party validator the RFC excludes, and keywords no model needs |
Consequences¶
- Easier: one model gives the validator, the stored shape and portalr's types.
- Harder: the TypeScript side repeats the subset;
tests/ui/vectors/forms.jsonholds the cases both sides test.