Skip to content

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.

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 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.

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.

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.

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 type governs 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 lookup domain), and paths may embed {{domain.path}} segments for cascading selects. A dependent select whose parent is unanswered renders disabled and fails closed. See Lookups.

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 json list 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); minItems and maxItems bound the length (At least 1 item, At most 2 items); a json list checks each item against items, including items.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-shaped additionalProperties ("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.

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-showIf at submit time is skipped, as with required.
  • A source that resolves to a missing or empty array makes the guard vacuous.
  • Listing the field in required means at-least-one; x-lt-require-all is 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.

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 required must 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 required and x-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 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)"
}
}
}

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.

workflows/quality-review.ts
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.

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.

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.