Skip to content

Declarative validation

A command declares its business-rule validation in YAML: cross-field rules in the core expression language plus validation SQL — SELECTs whose returned rows are the violations (uniqueness, existence, balance checks) — executed inside the command’s transaction, before a single step writes. Violations come back as a field-scoped 422 Unprocessable Entity with a stable error model: rule ids, field paths, rule codes, and message keys, localized at render time through the app’s message catalogs (internationalization.md). Input constraints (input: type, required, range, enum) still reject malformed requests with 400 at bind time; validate: is the business-rule layer behind them.

The declared-input vocabulary covers what LOB forms actually need, so simple rules never leak into SQL. On any input: field:

input:
email:
type: string
format: email # email | uuid | url — semantic validators for string fields
pattern: ".+@corp[.]example" # anchored regex (full match); lint pre-compiles it
minLength: 6
maxLength: 320
price:
type: number
min: 0.5 # decimal-exact bounds: 0.49 violates min: 0.5
max: 9999.99 # and 10000 violates max: 9999.99 — no float drift
note:
type: string
requiredWhen: params.kind == 'noted' # conditional requiredness, the core
# expression language over the request context
  • For date/datetime/number fields, format: remains the locale-aware parse pattern; for string fields it is one of the semantic validators above.
  • requiredWhen is pre-compiled at build (bad syntax fails the build; TQL-YAML-1014 in lint) and evaluated after every input is coerced, against the same params.*/path.*/ body.* namespaces expressions use elsewhere. An absent field whose condition holds is rejected exactly like required: true.
  • Typed path parameters: a path segment declared under input: (the scaffold’s {id} routes do this) is coerced and validated like any input, and the path.* namespace carries the typed value; an undeclared path parameter stays a raw string.
  • Every rejection is the field-scoped TQL-FIELD-2001 shape with a stable code (pattern, minLength, email, uuid, url, …) and a localizable tql.input.<code> message (en/ja built-ins included), rendered inline on the htmx path like every other field error.
  • The constraints ride into the generated OpenAPI (pattern, minLength/maxLength, minimum/maximum, format: email|uuid|uri, enums) — the contract and the enforcement are one declaration.

Line items: the contract inside an object array

Section titled “Line items: the contract inside an object array”

A header-plus-lines document — the order and its lines, the journal entry and its postings — is the most common shape a business form submits, and the deny-by-default posture input: holds at the top level applies inside it. An array of objects declares its element contract under items.fields:, a map of the same fields the top level takes:

input:
lines:
type: array
required: true
items:
fields:
itemId: { type: string, required: true, pattern: "[A-Z]{2}-[0-9]{3}" }
qty: { type: integer, required: true, min: 1, max: 9999 }
desiredDate: { type: date, required: false }
note: { type: string, requiredWhen: "item.qty > 100" }
  • Elements bind, coerce and validate exactly as top-level fields do, so params.lines carries typed values — a %for detail insert binds line.qty as an integer and line.desiredDate as a date, not as whatever text arrived.
  • A violation addresses itself by index: the field error names lines[2].qty, riding the same TQL-FIELD-2001 shape and the same data-field attribute the htmx fragment already distributes, so a grid form can mark the offending cell without a second error contract.
  • domain:, codes:, enum:, pattern: and requiredWhen: work per element unchanged. An element’s requiredWhen: sees its own element as item.* and its position as item_index — a line’s requiredness is a property of that line, not of the request around it.
  • An undeclared element field follows the route’s inputPolicy.unknownFields, and a non-writable one follows its readOnlyFieldBehavior: the mass-assignment guard, one level down, at the same two codes.
  • One level deep. A line is flat: an array inside items.fields: is refused by lint (TQL-YAML-1027), as is an items: block declaring both type: (scalar elements) and fields: (object elements), and a policy: on an element field — per-row write authorization is not a thing this declaration can enforce, so gate the whole array instead.
  • The element contract rides into the generated OpenAPI and into an MCP tool’s inputSchema as an object with its properties and required list, so a caller — or a model — sees the line it has to send.

A size check is still a validate: rule (params.lines.size > 0): items.fields: describes one line, not how many of them an operation needs.

The same business field crosses many operations, and restating “an SKU is an uppercase code of at most 40 characters” in every route invites drift. A field domain declares the field once, app-wide, under domains/; routes reference it and state only what is operational:

domains/catalog.yml
version: tesseraql/v1
domains:
sku:
type: string
maxLength: 40
pattern: "[A-Z0-9-]+"
email:
type: string
format: email
maxLength: 254
classification: personal
mask: fixed
web/products/adjust/post.yml
input:
sku: { domain: sku, required: true }

The line between the two is enforced, not conventional: a domain may carry the field itself (type, bounds, pattern, format, enum, items, classification, mask), and is rejected if it declares the operational keys (required, requiredWhen, default, writableTQL-FIELD-4602), so a domain can never silently make a field mandatory across the application. A route may restate a domain key to specialize it; tightening is silent, loosening draws lint TQL-FIELD-4610. Unknown references fail the load (TQL-FIELD-4601), duplicate names across files fail the load (TQL-FIELD-4600), and a domain nothing references is flagged (TQL-FIELD-4611).

A domain may also say that its legal values are the codes of a code catalog rather than a fixed enum:

payment_method:
type: string
maxLength: 8
codes: payment_method

The binder then accepts only that catalog’s active codes, and the violation is the enum field error — a catalog is a dynamic enum, so nothing downstream learns a second shape. The same reference gives a form its <select> options and renders the name behind the code wherever the column appears.

Resolution happens when the app manifest loads: routes carry fully-populated fields afterwards, so binding, this page’s error model, OpenAPI emission, and validation coverage are unchanged.

The CRUD scaffolder generates domains/<table>.yml from column metadata (VARCHAR size → maxLength, temporal parse formats) and routes that reference it — re-scaffolding after a schema change updates the domains file, not every route.

A domains document may also carry the app-level constraint catalog — database constraint names mapped once instead of per route:

constraints:
uq_products_sku:
field: sku
code: duplicate

Every route inherits the catalog; a route-local errors.constraints entry overrides the catalog’s mapping by name. One rename in a migration is one edit in one file.

version: tesseraql/v1
id: members.register
kind: route
recipe: command-json
input:
email:
type: string
required: true
startDate:
type: string
endDate:
type: string
validate:
uniqueEmail: # the rule id; also the default rule code
file: check-email.sql # a SELECT returning violations
params:
email: body.email
field: email # the field path violations are reported against
code: duplicate
message: members.email.duplicate # a message key (see internationalization.md)
dateOrder:
when: body.endDate != null # optional guard; a falsy guard skips the rule
rule: body.endDate >= body.startDate # must hold for the input to be valid
field: endDate
code: end-before-start
steps:
- id: main
sql:
file: insert-member.sql
mode: update
keys: [id]
params:
email: body.email
response:
json:
status: 201
body:
memberId: steps.main.keys.id

Rules evaluate in their authored order and all of them run — the response carries every violation, so a form repaints once. Each rule declares exactly one of:

  • rule: — a cross-field expression in the core expression language: comparisons, &&/||/!, dotted paths over params, body, query, path, principal, tenant (params and query name the same map — the examples here use params). The language is whitelist-only — no method calls, no side effects.
  • file: — a validation SQL file, a plain SQL-tool-runnable 2-way SELECT. It executes on the command’s connection, inside the transaction, so it sees a consistent snapshot (and may lock rows with FOR UPDATE for balance checks). A non-SELECT fails at route build time: validation must not write.

A rule needed by more than one route — the create/update uniqueness pair, a posting-period check, a balance rule — is declared once under rules/ and referenced by name:

rules/catalog.yml
version: tesseraql/v1
rules:
skuIsFree:
file: sku-free.sql # relative to this document; rows are violations
binds: { sku: string, excludeId: integer } # the typed bind contract every reference must wire exactly
code: duplicate
# a route's validate: block
validate:
skuIsFree:
use: skuIsFree
params: { sku: params.sku, excludeId: params.id }
field: sku # the reporting target is this operation's input

The set carries what the rule is (the expression or SQL, the contract, default code/message); every reference carries its own wiring — params: (checked against binds: exactly, and the contract is typed: each bind’s declared type is checked against the referencing route’s input types at load), field:, when:, and code/message overrides. Ambient principal.* binds seed shared SQL exactly as route SQL, so they never appear in a contract. Resolution happens at manifest load; execution, this page’s error model, and coverage consume plain rules unchanged. Unknown references, contract mismatches, and use: combined with an inline rule:/file: fail the load (TQL-FIELD-4606..4608), as does a binds: contract that disagrees with the rule’s own SQL (TQL-FIELD-4609); an unreferenced rule, and a route-local rule that repeats a shared one, are linted (TQL-FIELD-4612/4613).

scaffold crud generates a …IsFree rule per single-column unique index, shared by the create and update routes (update excludes its own row through a conditional directive), so the pre-write friendly 422 and the constraint catalog’s post-write honesty compose. It also generates a …Exists rule per single-column foreign key — when:-guarded for nullable columns — the one file where “exists” grows into “exists and is active”. For a hand-authored example, the purchase-request gallery app’s duplicateRequest rule guards duplicate applications with the caller identified by the ambient /* principal.loginId */ bind, so its contract is a single title bind.

Where a validation rule answers “is this operation allowed?”, a decision table turns a combination of input conditions into declared output values: the approval route for an amount and a department, the shipping fee for a weight and a region. Decisions live in decisions/ next to domains/ and rules/, declare a typed contract, and are referenced from a command’s decide: block with the same use:/params: grammar as shared rules:

decisions/approval.yml
version: tesseraql/v1
decisions:
approvalRoute:
inputs:
amount: { domain: money, match: between }
outputs:
assignee: { type: string, enum: [approver-1, cfo-1] }
hitPolicy: first # first | unique
rows:
- when: { amount: "> 100000" }
outputs: { assignee: cfo-1 }
- outputs: { assignee: approver-1 } # the trailing row without when: is the default
# a route (or a workflow transition) evaluates it once, before validate: rules
decide:
approvalRoute:
use: approvalRoute
params: { amount: params.amount }

A row is the conjunction of its cells — equality, an inclusive range (between), membership in a small set (in), a boolean, or an org-subtree test (subtree, table sources) — and an absent cell is a wildcard. Alternatives are separate rows; derivations (“the caller holds the officer role”) belong in the decide: wiring, which is an expression over the request context. Outputs publish as decision.<alias>.<output> for SQL binds, /*%if … */ directives, step when: guards, and workflow guards; a lookup that matches nothing raises TQL-DECISION-4721 unless a default answers, never a silent null.

The rows have two homes with one contract: YAML rows for policy that changes with a release (linted at build for overlap, shadowing, and enum-value typos), or an app-owned table (source:) for data business users maintain at runtime — evaluated as one generated SELECT in the operation’s transaction, with effective: [from, to] dating and effectiveAt: for document-dated lookups. Enum-typed outputs buy build-time exhaustiveness: comparing an output to a value the decision cannot produce is TQL-DECISION-4713, and a workflow state whose guarded transitions leave a declared value unhandled is TQL-DECISION-4712. Declarative suites test the table itself with a decide: case — params are the input values, the matched row’s outputs are the expected row, and a miss comes back as code: TQL-DECISION-4721 so the no-silent-null contract is assertable — counted by the decision coverage kind. The purchase-request gallery app carries the worked example: approvalRoute resolves the submit transition’s assignee from the document’s amount, and its suite asserts both lanes and the threshold. The full surface is in the YAML reference and the TQL-DECISION-* rows of the error-code reference.

validate: rules, requiredWhen, response.html.headersWhen guards, and workflow guards share one deliberately small, side-effect-free expression language. It covers the arithmetic and string logic LOB rules actually need:

  • Operators (by precedence): ||, &&, ==/!=, </>/<=/>=, +/-, *///%, unary !/-, and (...) grouping. Arithmetic is decimal-exact (BigDecimalqty * price <= budget carries no float drift); + concatenates when either side is a string; a null operand propagates null.
  • Functions (whitelist-only — unknown names and wrong arities fail the build): the built-ins length(s), lower(s), upper(s), trim(s), contains(s, sub), startsWith(s, p), endsWith(s, p), matches(s, regex), abs(n), round(n), floor(n), ceil(n), min(a, b), max(a, b), coalesce(a, b), plus any custom functions installed from the app’s modules. Built-in predicates are null-safe (false on null), transforms propagate null.
  • There is no method invocation, reflection, or assignment.
validate:
overBudget:
field: total
code: over-budget
rule: params.qty * params.price <= params.budget
corpMail:
field: email
code: corp-mail
rule: matches(lower(trim(params.email)), '.+@corp[.]example')

When a rule needs one predicate the built-ins cannot express — a checksum, a code-format rule, a business-calendar check — you do not have to fall back to validation SQL or a full runtime extension. Implement the ExpressionFunction SPI (tesseraql-core, dependency-free), one class per function:

public final class IsKatakana implements ExpressionFunction {
public String name() { return "isKatakana"; }
public int arity() { return 1; }
public Object apply(List<Object> args) {
return args.get(0) != null
&& String.valueOf(args.get(0)).matches("[\\u30A0-\\u30FF]+");
}
}

Register it in the jar’s META-INF/services/io.tesseraql.core.expr.ExpressionFunction, publish the jar, and declare it like any other module:

config/tesseraql.yml
tesseraql:
modules:
- com.example:example-expression-functions

dev, lint, test, coverage, and mcp all install the functions from the resolved modules classpath before parsing (the CLI’s --modules <dir> composes for local jars; the Maven goals discover functions declared as plugin dependencies). Once installed, the function is callable wherever the expression language runs — validate: rules, 2-way SQL /*%if*/ directives, requiredWhen, notify when: guards, workflow guards:

validate:
kanaName:
field: kanaName
code: not-kana
rule: isKatakana(trim(body.kanaName))

The rules that keep the language safe still hold:

  • The purity contract. A function must be side-effect-free (no I/O, no state mutation), total (return null/false for absent or mismatched values instead of throwing, like the built-ins), and fast — expressions evaluate on every request and inside validation transactions. This is a contract, not a sandbox: the modules set is the reviewed, lock-pinned channel (modules.lock), which is exactly why functions load from it and not from plugins/.
  • Fail-fast installation. A name that is not a legal identifier, shadows a built-in, or is contributed twice stops the command with TQL-SQL-2110 — a broken function jar can never silently change what an existing expression means.
  • Parse-time whitelist. Unknown names and wrong arities are still build errors. An app that calls custom functions therefore fails tesseraql admission (the declarative-only gate lints without the app’s modules), which is intentional: custom Java is extended territory, not marketplace territory — see admission.md.
select
'email' as field
from
members
where
email = /* email */'taken@example.com'

An empty result means the input is valid. Each returned row becomes one violation; columns named field, code, or message override the rule’s declared defaults per row, and any other column rides along into the error payload — so a balance check can return the offending line number. The SELECT’s author decides what is exposed; never select internal diagnostics.

A violating request answers 422 with TQL-FIELD-4220:

{"error": {"code": "TQL-FIELD-4220", "message": "Unprocessable Entity",
"details": {"fields": [
{"rule": "uniqueEmail", "field": "email", "code": "duplicate",
"messageKey": "members.email.duplicate", "message": "Already exists."},
{"rule": "dateOrder", "field": "endDate", "code": "end-before-start"}]}}}

code defaults to the rule id. The declared message key rides as messageKey, and message carries the localized text resolved with the request locale — the app catalog’s entry for the key, falling back to the built-in tql.constraint.<code> texts (internationalization.md). The top-level message is the localized status phrase. htmx callers (HX-Request: true) receive the same details as the Hypermedia Components field-errors fragment; the kit’s auto-installed installFieldErrors behavior distributes each item next to the input matching its data-field (with aria-invalid/aria-describedby wiring) and resolves data-message-key through the kit’s message catalog:

<div class="hc-alert" data-variant="error" role="alert" data-hc-field-errors
data-error-code="TQL-FIELD-4220">
<p class="hc-alert__title">Unprocessable Entity</p>
<ul class="hc-alert__errors">
<li class="hc-alert__error" data-field="email" data-code="duplicate"
data-message-key="members.email.duplicate">Already exists.</li>
</ul>
</div>

Because validation runs first in the transaction, a violation rolls back having written nothing — sequences, steps, and outbox events all stay untouched.

A suite case can target a route’s rules directly — the violations are the case’s rows — so a rule is testable without serving the route:

tests:
- name: a taken email is rejected
validate:
route: members.register # evaluates the route's whole validate: block
params:
body:
email: taken@example.com
startDate: "2026-01-01"
expect:
rowCount: 1
rows:
- rule: uniqueEmail
field: email
code: duplicate
- name: ordered dates pass the cross-field rule
validate:
route: members.register
rule: dateOrder # optional: narrow the case to one rule
params:
body:
startDate: "2026-01-01"
endDate: "2026-12-31"
expect:
rowCount: 0

The case’s params: map is the execution context the rules see (typically a body: map). SQL rules run against the test database and record line/branch coverage like SQL-file cases.

Every rule of every route’s validate: block is declared as <routeId>.<ruleId>; a validation case covers the rules it evaluates (the targeted rule, or the route’s whole block when no rule: is named). Gaps surface in the coverage report and as SARIF findings, and a coverage.thresholds.validation threshold gates the build like any other kind.

Lint reports statically what would otherwise fail at route build time:

  • validate: on a non-command recipe (TQL-YAML-1003)
  • a rule with both or neither of rule:/file:, a missing field:, or validation SQL that writes (TQL-FIELD-2003)
  • malformed when:/rule: expressions (TQL-SQL-2101)
  • a missing rule SQL file (TQL-SQL-2103)
Code Status Meaning
TQL-FIELD-4220 422 declarative validation rejected the input
TQL-FIELD-2003 invalid validation rule declaration (build/lint time)
TQL-YAML-1003 lint: validate: on a non-command recipe
TQL-YAML-1027 lint: invalid items.fields: element contract
TQL-FIELD-2002 400 a body that is not a JSON object, or an element’s undeclared or non-writable field