Validation and options
The form runs a full validation pass before it accepts a submit. Every visible field is checked; a failure shows an inline error and holds the submit. The side panel opens on a list of every error, and clicking an entry scrolls to and focuses its field.
Roles with enforce_schema run the same pass on the server, so an API, MCP or SDK caller receives the same violations as a canonical 422. See Schema enforcement.
Required
Section titled “Required”Fields in the schema’s required array hold the submit while empty. A red asterisk marks them. Empty means null, undefined, blank text, an unchecked checkbox, an object with no truthy value, an empty multi-select, or an empty json list. A boolean with an option list is answered by either value, and a json map is answered by any key. A field hidden by x-lt-showIf at submit time is waived.
{ "required": ["approved", "reference_code"], "properties": { "approved": { "type": "boolean" }, "reference_code": { "type": "string" } }}Format guards
Section titled “Format guards”| Format | Guard |
|---|---|
email |
Must match user@host.tld |
uri |
Must start with https:// or http:// |
Format errors appear beneath the field as soon as it loses focus.
String length bounds
Section titled “String length bounds”| Keyword | Source | Value |
|---|---|---|
minLength |
JSON Schema | Static integer |
maxLength |
JSON Schema | Static integer |
x-lt-min-length |
Long Tail | "domain.path" resolved at submit time |
x-lt-max-length |
Long Tail | "domain.path" resolved at submit time |
A static bound wins over its dynamic twin; the path applies only when no static value is set.
{ "properties": { "reference_code": { "type": "string", "minLength": 3 }, "notes": { "type": "string", "format": "textarea", "x-lt-max-length": "envelope.max_notes_length" } }}When envelope.max_notes_length is 500, the notes field holds the submit past 500 characters with Maximum 500 characters (534 entered). A textarea with a length bound shows a live X / N counter that turns red past the limit.
Numeric bounds
Section titled “Numeric bounds”| Keyword | Source | Value |
|---|---|---|
minimum |
JSON Schema | Static number |
maximum |
JSON Schema | Static number |
exclusiveMinimum |
JSON Schema | Static number |
exclusiveMaximum |
JSON Schema | Static number |
x-lt-minimum |
Long Tail | "domain.path" resolved at submit time |
x-lt-maximum |
Long Tail | "domain.path" resolved at submit time |
A static bound wins over its dynamic twin.
{ "properties": { "score": { "type": "number", "maximum": 100, "x-lt-minimum": "envelope.min_score" } }}When envelope.min_score is 60, a score of 45 holds the submit with Minimum value is 60.
Select options (x-lt-options)
Section titled “Select options (x-lt-options)”x-lt-options takes one of three forms:
- A
"domain.path"string resolves the list from the context. - An array of path strings resolves the first path that yields options.
- An array with no string entries is the list itself:
{ value, label }objects, numbers or booleans. Write a literal string option as an object,{ "value": "xl", "label": "Extra large" }, since a bare string is always read as a path.
A static enum wins over both.
{ "properties": { "size": { "type": "string", "x-lt-options": [{ "value": "s", "label": "Small" }, { "value": "xl", "label": "Extra large" }] }, "powerCycled": { "type": "boolean", "x-lt-options": [{ "value": true, "label": "Yes" }, { "value": false, "label": "No" }] } }}A boolean with an option list renders as a select. Use it for decisions, and keep the checkbox for confirmations.
Clearable selects. A select opens on a disabled Choose… placeholder. With "x-lt-nullable": true the placeholder stays enabled and picking it submits null, for a choice that may lawfully stay unmade.
The list can ride the escalation instead of the schema. The array of strings or numbers at the path becomes the field’s options: one static role form, legal values per row.
{ "properties": { "quantity": { "type": "number", "default": 0, "x-lt-options": "envelope.quantity_options" }, "destination": { "type": "string", "x-lt-options": "envelope.return_stations" } }}When the envelope carries quantity_options: [0, 1, 2, 3], the field is a dropdown offering exactly those values. The workflow sends only the small per-row array; the form stays on the role.
- An ordered array of paths, such as
["lookup.reasons.items", "envelope.reject_reason_items"], resolves the first path that yields options. Lookup-backed and envelope-embedded rows render the same schema, so rows parked before a migration to a lookup fall through to the envelope they carry. A plain string is a one-element array. - Entries are scalars or
{ value, label }objects ({ id, label }is an alias). The select shows the label and submits the value, so a pick list can display text while storing a foreign key. A scalar is both. Malformed entries are dropped. - The declared
typegoverns the value: a number field emits a number, a string field a string. - While the current value sits outside the list (an empty start, options that shrank for this row), the select opens on the disabled Choose… placeholder.
- When no path resolves (missing, empty, no valid entries) the field renders as the plain input for its type and membership is waived for that submit. When any path embeds
{{domain.path}}interpolation, the field stays a disabled select and a present value fails closed, so a cascade child stays a select. - Membership is checked on the option value on both sides of the wire: the dashboard constrains the choices, and an enforced role’s server gate answers a value outside the row’s list with a
422(Must be one of: 0, 1, 2, 3). - Option lists can come from versioned knowledge entries (the
lookupdomain), and paths may embed{{domain.path}}segments for cascading selects. A dependent select whose parent is unanswered renders disabled and fails closed. See Lookups.
Lists and maps
Section titled “Lists and maps”A type: "array" field with its own x-lt-options (path or literal) renders as a multi-select of chips; the submitted value is the array of picked values in list order. A list is validated when it is edited this way or through the json widget. A list shown as read-only tags is left as is.
{ "required": ["colors"], "properties": { "colors": { "type": "array", "x-lt-options": [{ "value": "red", "label": "Red" }, { "value": "blue", "label": "Blue" }], "minItems": 1, "maxItems": 2 }, "weights": { "type": "object", "x-lt-widget": "json", "propertyNames": { "enum": ["small", "medium", "large"] }, "additionalProperties": { "type": "number", "minimum": 0, "maximum": 1 } } }}- A required multi-select or
jsonlist needs at least one item. An empty optional list passes its constraints. - Every item must belong to the option list (
Must be one of: red, blue);minItemsandmaxItemsbound the length (At least 1 item,At most 2 items); ajsonlist checks each item againstitems, includingitems.enum(Item 2: Expected a number). - A map’s keys must belong to
propertyNames.enum(Unknown key "tray". Allowed: small, medium, large) and every value must satisfy an object-shapedadditionalProperties("small": Minimum value is 0). A required map needs at least one key; a zero weight is an answer. - An API or MCP caller sending the list or map under the bound key receives the same violations the form shows.
Checklist completion (x-lt-require-all)
Section titled “Checklist completion (x-lt-require-all)”A checklist field can require every item checked before submit:
{ "properties": { "checks": { "type": "object", "x-lt-widget": "checklist", "x-lt-source": "envelope.checklist_items", "x-lt-require-all": true } }}The pass fails until every rendered item is checked, except items whose definition carries required: false (an optional photos step, for example). An item with required: true or no required key must be checked.
- A field hidden by
x-lt-showIfat submit time is skipped, as withrequired. - A source that resolves to a missing or empty array makes the guard vacuous.
- Listing the field in
requiredmeans at-least-one;x-lt-require-allis stricter and wins when both are present.
On a held submit, each unchecked mandatory item highlights red, and the error panel reads N of M checks incomplete, focusing the first unchecked item on click. Errors clear as items are checked. The group carries aria-invalid and the message is announced as an alert.
Require any of a set (x-lt-require-any)
Section titled “Require any of a set (x-lt-require-any)”A schema root can require a value in at least one field of a group: a tracking number or a pickup code satisfies, both blank fails.
{ "x-lt-require-any": [["tracking_number", "pickup_code"]], "properties": { "tracking_number": { "type": "string", "title": "Tracking number" }, "pickup_code": { "type": "string", "title": "Pickup code" } }}The token is an array of groups; each group is an array of property names and enforces independently. A group passes when any member carries a value: false and 0 are answers; empty string, null and undefined are not.
- A member hidden by
x-lt-showIf(or naming no schema property) neither satisfies the group nor is demanded, and a group whose members are all hidden is waived. - An unsatisfied group yields one violation on its first visible field:
Enter a value for at least one of: Tracking number, Pickup code(member titles, falling back to keys). - A field listed in
requiredmust be filled regardless; the group check layers on top. - The dashboard holds the submit, and an enforced role’s server gate answers a violating payload with a
422.
Require a combined quantity (x-lt-require-sum)
Section titled “Require a combined quantity (x-lt-require-sum)”For quantity fields, 0 is an answer, so counts that default to 0 satisfy x-lt-require-any vacuously. x-lt-require-sum reads the members as numbers and demands their sum reach a minimum, so an all-zero submission is held:
{ "x-lt-require-sum": [{ "fields": ["small_quantity", "large_quantity"] }], "properties": { "small_quantity": { "type": "number", "title": "Small quantity", "default": 0 }, "large_quantity": { "type": "number", "title": "Large quantity", "default": 0 } }}The token is an array of groups; each group is { "fields": [...], "minimum": N } and enforces independently. minimum defaults to 1. Numbers contribute their value, numeric strings coerce, and anything else (empty, missing, non-numeric) contributes 0.
- Visibility composes as with
x-lt-require-any: a hidden member contributes nothing and is not demanded, and an all-hidden group is waived. - An unsatisfied group yields one violation on its first visible field:
Combined value of Small quantity, Large quantity must be at least 1. - It composes with
requiredandx-lt-require-any; each guard reports independently. - The dashboard holds the submit, and an enforced role’s server gate answers a violating payload with a
422.
Pattern guard
Section titled “Pattern guard”pattern applies a regular expression. Input that fails to match holds the submit. x-lt-pattern-error sets the message; the default is Invalid format.
{ "properties": { "reference_code": { "type": "string", "pattern": "^[A-Z0-9][A-Z0-9-]*[A-Z0-9]$|^[A-Z0-9]$", "x-lt-pattern-error": "Use uppercase letters, digits, and dashes (for example QA-2024-001)" } }}Dynamic bounds and domain paths
Section titled “Dynamic bounds and domain paths”x-lt-minimum, x-lt-maximum, x-lt-min-length, x-lt-max-length and x-lt-options accept a "domain.path" string, resolved from the escalation context at submit time. It is the same convention as x-lt-showIf and x-lt-help tokens.
| Domain | Resolves against |
|---|---|
envelope |
The envelope the workflow sent |
metadata |
The row’s metadata |
payload |
The escalation context payload |
escalation |
Top-level escalation row fields |
lookup |
Versioned knowledge entries, pinned or current |
resolver |
The answers in the form so far |
The path is dot-separated with optional [n] array indices. A missing or non-numeric value at a bound’s path leaves that bound unenforced for the submit.
const decision = await conditional<{ score: number; notes: string }>(signalId, { role: 'quality-reviewer', envelope: { min_score: 60, max_notes_length: 500, },});The role’s form schema carries the bound tokens ('x-lt-minimum': 'envelope.min_score', 'x-lt-max-length': 'envelope.max_notes_length'); the envelope carries the per-row values.
Error panel
Section titled “Error panel”When the pass finds errors, the side panel opens on the Errors view: a numbered list of field names and messages. Each entry is a button that scrolls to its field and focuses it. The panel icon shows a ! badge while errors are active.
The panel updates as fields are corrected: fixing a field removes its entry, and the panel clears once every issue is resolved.
Reference examples
Section titled “Reference examples”The constraint-form example workflow in the long-tail repo covers every constraint guard:
| Field | Guards |
|---|---|
approved |
Boolean toggle |
rejection_reason |
Required, shown with x-lt-showIf: '!resolver.approved' |
reference_code |
pattern, minLength, x-lt-pattern-error |
score |
maximum: 100, x-lt-minimum: 'envelope.min_score' |
notes |
x-lt-max-length: 'envelope.max_notes_length' |
checks |
Checklist widget with x-lt-source: 'envelope.checklist_items' |
The quality-reviewer role is seeded with two test escalations carrying different min_score and max_notes_length values, so every dynamic bound is exercisable without code changes.
The parameterized-form example is the reference for per-row parameterization: number selects whose x-lt-options come from the envelope, a string select of destinations, and an x-lt-require-sum pair that must total at least 1. Its verdict-reviewer role is seeded with escalations whose envelopes carry different ranges, so one role form offers different legal values per row.
