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.
Input constraints
Section titled “Input constraints”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/numberfields,format:remains the locale-aware parse pattern; for string fields it is one of the semantic validators above. requiredWhenis pre-compiled at build (bad syntax fails the build;TQL-YAML-1014in lint) and evaluated after every input is coerced, against the sameparams.*/path.*/body.*namespaces expressions use elsewhere. An absent field whose condition holds is rejected exactly likerequired: 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 thepath.*namespace carries the typed value; an undeclared path parameter stays a raw string. - Every rejection is the field-scoped
TQL-FIELD-2001shape with a stable code (pattern,minLength,email,uuid,url, …) and a localizabletql.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.linescarries typed values — a%fordetail insert bindsline.qtyas an integer andline.desiredDateas a date, not as whatever text arrived. - A violation addresses itself by index: the field error names
lines[2].qty, riding the sameTQL-FIELD-2001shape and the samedata-fieldattribute the htmx fragment already distributes, so a grid form can mark the offending cell without a second error contract. domain:,codes:,enum:,pattern:andrequiredWhen:work per element unchanged. An element’srequiredWhen:sees its own element asitem.*and its position asitem_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 itsreadOnlyFieldBehavior: 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 anitems:block declaring bothtype:(scalar elements) andfields:(object elements), and apolicy: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
inputSchemaas 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.
Field domains
Section titled “Field domains”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:
version: tesseraql/v1domains: sku: type: string maxLength: 40 pattern: "[A-Z0-9-]+" email: type: string format: email maxLength: 254 classification: personal mask: fixedinput: 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, writable —
TQL-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_methodThe 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: duplicateEvery 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.
The validate block
Section titled “The validate block”version: tesseraql/v1id: members.registerkind: routerecipe: 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.idRules 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 overparams,body,query,path,principal,tenant(paramsandqueryname the same map — the examples here useparams). 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 withFOR UPDATEfor balance checks). A non-SELECT fails at route build time: validation must not write.
Shared rule sets
Section titled “Shared rule sets”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:
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: blockvalidate: skuIsFree: use: skuIsFree params: { sku: params.sku, excludeId: params.id } field: sku # the reporting target is this operation's inputThe 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.
Decision tables
Section titled “Decision tables”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:
version: tesseraql/v1decisions: 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: rulesdecide: 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.
The expression language
Section titled “The expression language”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 (BigDecimal—qty * price <= budgetcarries no float drift);+concatenates when either side is a string; anulloperand propagatesnull. - 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 (falseon null), transforms propagatenull. - 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')Custom functions
Section titled “Custom functions”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:
tesseraql: modules: - com.example:example-expression-functionsdev, 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/falsefor 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 fromplugins/. - 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 isextendedterritory, not marketplace territory — see admission.md.
Validation SQL: rows are violations
Section titled “Validation SQL: rows are violations”select 'email' as fieldfrom memberswhere 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.
The error model
Section titled “The error model”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.
Testing rules in declarative suites
Section titled “Testing rules in declarative suites”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: 0The 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.
The validation coverage kind
Section titled “The validation coverage kind”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 missingfield:, or validation SQL that writes (TQL-FIELD-2003) - malformed
when:/rule:expressions (TQL-SQL-2101) - a missing rule SQL file (
TQL-SQL-2103)
Error codes
Section titled “Error codes”| 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 |
-
extending.md — where a custom function sits on the extension ladder.
-
transactional-writes.md — the commands these rules guard.
-
internationalization.md — translating the messages a rule produces.
-
testing.md — exercising rules declaratively.