Libertas Data Validation Rules Specification
Version: 1.11
This document is normative for the ValidationRules schema attribute,
including client evaluation and presentation of each rule's supplied message.
1. Purpose
Libertas Data Validation Rules define a small, statically analyzable language for expressing semantic validity constraints over schema-compliant data.
The input to validation is:
- a schema (\Sigma),
- one concrete or partially entered
datainstance, and - optional
referencedData: the reference context supplied to evaluation, containing its ordered reference objects, ornullwhen no context is available.
A rule is evaluated as Eval(rule, data, referencedData). The schema supplies
the rule and type/path interpretation. Under the
shared reference context, ordinary curly
references read the current interaction reference context.
referencedData carries that lookup environment without changing this
evaluation signature. External references follow its source order and the
shared first-non-null lookup rule.
It does not imply a fetch, I/O, stored history, or a merged reference object.
The rule language does not define GUI presentation, behavior, state transitions, mutation, or effects.
A rule is a pure expression of data and referencedData. During editing, any field
MAY temporarily be undefined (unanswered), including a Nullable field whose
Nullable choice has no answer yet.
Undefined is local editor state, never encodable and never an answer; null
in a rule always means a present null, such as a Nullable's chosen null
answer. A rule that reads an unanswered value is aborted: the client does
not report its result and reports that value as a missing answer instead, in
incremental and final validation alike. During editing the missing answer is
the field's unanswered indicator, never a failure that rejects an incremental
change; at final validation it blocks submission like any unanswered field. A rule's result therefore never
depends on an unanswered value, and no rule distinguishes null from undefined.
Within a rule, null serves as a fallback value, chiefly through
|, not as a test of whether the person answered: whether
a field must be answered is structural, declared by a non-Nullable field or
a Nullable field without a Default. Mandatory-field validation and
ValidationRules are complementary, independent requirements. A rule MAY
accept a null, but that success does not make the field optional or
suppress the final mandatory-field check.
Final submission separately requires structural schema validity, every field
answered (a non-Nullable field present, a Nullable field null or present),
and all validation rules to pass. A zero-length
MIME-aware Bytes value is a present, encodable
Bytes field but has undefined media meaning and evaluates as null. Its
non-Nullable declaration and size bounds do not require media selection;
an App requiring a file declares a rule such as
[] != null : "IMAGE_REQUIRED";. A defined LMB1 value evaluates as non-null,
even when its content is empty. Ordinary Bytes keep their existing valuation.
ValidationRules uses the shared schema reference contract. Rules may occur in editable Request/SubscriptionRequest data, inherited types, list items, and hidden/read-only fields. External reference reads require neither CopyFrom nor request-side copies of server metadata.
Authors MUST account for first-time request composition with no reference context:
every curly reference evaluates to null in that state.
ValidationRules must handle that absence explicitly, for example with a null
guard or a typed | fallback. The client still evaluates the rule normally;
absence does not automatically make the rule pass. Neither validation nor
request composition fetches server data to resolve these references.
For the supplied referencedData, the data instance is valid iff every
validation rule evaluates to true.
[ Valid_\Sigma(data,referencedData) \iff \bigwedge_{rule\in R} Bool(Eval(rule,data,referencedData)) ]
2. Data Model
A schema MAY contain:
- primitive values
- boolean
- integer
- floating-point number
- string
- DateOnly calendar date
- DateTime instant
- other schema-defined primitive types
- structs
- arrays
- nullable values
- union types
Example:
struct A {
int value;
}
struct B {
string name;
}
struct Root {
A | B item;
float? limit;
Entry[] entries;
}
Structural schema compliance is outside the scope of the validation-rule
language. An evaluator MUST nevertheless tolerate the transient null values
used by client editors and MUST remain total for that incomplete state.
Every successful intermediate evaluation carries an internal semantic type;
operators MUST NOT infer a new type merely from the scalar's storage encoding.
In particular, DateOnly is a calendar type even though its schema and wire
encoding is the integer yyyyMMdd, and DateTime is an instant type even though
its schema and wire encoding is integral Unix epoch seconds. Literals, paths,
fallback results, operator results, and function results all retain or produce
an explicit internal type. null has its own type. A
DateOnly literal has type DateOnly; the language has
no DateTime literal.
A Bitflags host,
an integer Number whose Bitflags attribute names a flag catalog, is an
ordinary integer in rules. Its catalog's members are schema constants that
appear only as the right operand of the membership test has.
3. Rule Set
Rules are stored in one string-valued schema attribute:
{ "Name": "ValidationRules", "Value": "[min] <= [max] : \"INVALID_RANGE\";" }
ValidationRules MAY be declared on:
- a schema function, where the evaluation root is a synthetic Table containing all function arguments under their schema names;
- any schema data field; or
- any schema data type, including a named primitive, Table, List, Union, or other named Libertas type.
A field/type rule is rooted at each concrete value of the annotated node. A
named-type rule and a field-local rule are cumulative when a field references
that named type. Inherited field rules are cumulative as well. The empty data
path [] denotes the annotated value itself. Function rules address arguments
by name, such as [minimum] and [options/timeout].
The TypeScript source spelling is
@LibertasValidationRules("..."). The Rust source spelling is
#[libertas_validation_rules("...")]. A Rust named type may use the
standalone attribute; fields and variants use the LibertasExport helper
attribute. Parsers emit the raw string and source location. Libertas Studio's
shared schema validator owns grammar and schema-decidable semantic validation.
A validation rule set is a sequence of expression/message pairs. Every Boolean
expression is followed by one :, a quoted package StringResources key, and
the terminating ;:
[min] <= [max] : "INVALID_RANGE";
The key is metadata for that complete expression and does not participate in
Boolean evaluation. It MUST be declared by the package StringResources
catalog and have a non-empty value in the default-locale document. Studio
enforces both requirements. A bare expression; is invalid.
A rule set MAY begin with this case-sensitive phase directive:
FinalOnly=true;
or explicitly:
FinalOnly=false;
The default is false. The directive, when present, MUST be the first item,
MUST occur only once, and accepts only the lowercase literals true and
false. It is metadata inside the ValidationRules string; it is not an
individual schema attribute.
An omitted directive or FinalOnly=false places the rule set in both
incremental editor validation and final validation. Because an incremental
failure restores the previous value, such a rule set MUST tolerate incomplete
editing; an unanswered value never reaches a rule. FinalOnly=true explicitly
omits the entire rule set from incremental validation and evaluates it only
during final validation. This removes the need for that rule set to tolerate an
initial incomplete editor state. It does not alter null expression semantics, change Nullable, or
replace final static schema checks. When field, inherited, and named-type rule
sets accumulate, each declaration retains its own FinalOnly value.
Authoring guidance
Use the schema's precise type and ordinary structural attributes for
nullability, numeric ranges, sizes, and uniqueness. ValidationRules express
semantic relationships that those declarations cannot express; they do not
restate or replace structural constraints.
When the person must answer a field, declare it structurally: a non-Nullable
field, or a Nullable field without a Default, whose
Nullable choice starts unanswered. Do not
restate that requirement as a != null rule; an unanswered value never
reaches a rule.
Where a check belongs
The App's save path is the authoritative validator. It checks every Request it receives in full, whether or not a client evaluated any rule. Client rules are a convenience for the person editing; they are never a security or correctness boundary, and never the only copy of a check.
| Check | Where it belongs |
|---|---|
| Type, nullability, numeric range, size, uniqueness, String format | Schema declarations and Pattern. The client enforces them while editing, and the SDK's Avro decoder enforces each static bound (a number's min, max and step included), and that every DateOnly value is a real Gregorian date, when the App decodes a Request, so App code and tests never re-check them. Dynamic RangeMin/RangeMax limits stay save-path checks. |
| Simple, local relationship where immediate feedback helps the person (one field, or fields on the same page) | Optionally a rule set with the directive omitted (incremental and final), mirroring a save-path check. |
| Business logic: cross-page or cross-record consistency, legal or eligibility rules, anything needing App state | The save path only. Reject with an Error response and name the field with ErrorFieldPath. |
Final-only rule sets (an explicit FinalOnly=true;) are
discouraged. They run at submission, the same moment as the save-path check, so
they add no earlier feedback; they only add a second copy of the logic that can
drift from the save path or fail silently. Put submission-time checks in the
save path instead. Final-only rule sets remain valid and are still evaluated.
Omitting the directive means the rule set is also evaluated incrementally;
final-only must be written explicitly.
Example:
[min] <= [max] : "INVALID_RANGE";
[value] >= [min] &&
[value] <= [max] : "VALUE_OUT_OF_RANGE";
[end] > [start] : "END_BEFORE_START";
All rules are combined using logical AND.
Therefore:
r1 : "ERROR_1";
r2 : "ERROR_2";
r3 : "ERROR_3";
is semantically equivalent to:
r1 && r2 && r3 : "COMBINED_ERROR";
A data instance is valid only when every rule evaluates to true. The two
forms above are validity-equivalent but intentionally not
presentation-equivalent: the first form can display the key belonging to the selected
failed rule, while the combined expression owns one message key.
Debugging rules
Studio offers an opt-in developer trace for each applicable rule. It records
the expression text, message key, and source file and one-based line when
known (otherwise null), and a passed, failed, or undecided outcome.
undecided means an evaluated read encountered an unanswered editor value;
it is not a rule failure. Null or missing hosts and inactive Union payloads
remain ordinary absence under the path rules, not unanswered values.
The trace records evaluated paths as typed values, unanswered reads, absent
values, or ordered List fan-out, and records evaluated operator and function
results, including |, is, and has. Numeric results retain exact rational
values; date results retain their semantic type. Skipped branches and cursor
iterations are never evaluated for debugging. Developer evaluation may inspect
all applicable rules independently in declaration order while returning the
same first violation as ordinary evaluation. Without a requested trace,
ordinary evaluation retains its existing work and first-failure behavior.
The control view's developer-only Why disclosure shows this trace under a failing or undecided rule. Expressions and entered values never appear in normal user presentation. Debugging changes neither stored data nor client protocols, and a trace does not replace structural validation.
Use evaluate_validation_rules for saved
App source and supplied data. For CI, test-rules <fixtures...> is a mode of
the same installed Studio schema compiler launcher configured for MCP:
node /path/to/launcher.cjs test-rules fixtures/*.json. It uses the same
compilation and evaluation. Each JSON fixture supplies project, optional
entry and phase, type, value in schema JSON value form, and expected,
an object mapping message keys to passed, failed, or undecided.
Relative project paths resolve from the fixture's directory. The runner reports
every fixture and exits zero only when compilation and all expectations pass;
invalid fixtures and mismatches exit nonzero. Every evaluated rule key requires
an expectation, and unknown expected keys fail. For a key evaluated at several
value occurrences, its outcome is failed if any occurrence fails, otherwise
undecided if any is undecided, otherwise passed. A rule expected to fail is a
successful test when it fails as expected. Standard output is one JSON report
containing per-fixture results, diagnostics, traces and a summary.
3.1 Dynamic numeric ranges
RangeMin and RangeMax reuse the Data Validation expression language to
derive live inclusive constraints for a Number or DateTime value. Their source spellings
are @LibertasRangeMin("expression") / @LibertasRangeMax("expression") in
TypeScript and #[libertas_range_min("expression")] /
#[libertas_range_max("expression")] in Rust. The language-neutral schema
stores the decoded expression string under RangeMin or RangeMax.
The numeric annotation also accepts a quoted expression in min or max:
@LibertasNumber(type=i64, min=0, max="[^/maximum]") or
#[libertas_number(min=0, max="[^/maximum]")]. Unquoted numeric literals
and Rust $CONST values continue to emit NumberMin/NumberMax; quoted
expressions emit RangeMin/RangeMax, exactly like the standalone range
annotations. Rust source constants inside expression text use the existing
constant substitution. step remains a fixed numeric value (or Rust source
constant). No additional schema attributes are introduced.
Each attribute contains exactly one value expression. It MUST NOT contain a
validation message pair (: "KEY";), multiple expressions, or a FinalOnly
directive. The expression MUST produce a bound compatible with the annotated
node's semantic type, not a Boolean validation predicate. Studio MUST validate
its grammar, paths, calls, operand types, and result type against that node. A
result type known to be incompatible MUST be rejected during schema validation;
unknown reference types remain subject to runtime validation. Nullability does
not make a known incompatible non-null result type valid.
Evaluation uses the same field-local context as ValidationRules: [] is the
annotated Number or DateTime and square paths traverse from it. The shared
current-data path contract
permits a leading parent prefix: [^/minimum] reads the current row's
minimum, and [^^/limits/maximum] reads a bound two levels above the field.
Limits read current values whenever the normal editor refreshes and during
final validation; rows bind independently, including after insertion, removal,
reordering, or replacement. Curly paths
inspect the current interaction reference context,
including in editable requests. The result type must match the control's semantic type:
| Annotated control | Required non-null result |
|---|---|
| Ordinary Number, including TimeInterval | Finite Number; integer and fractional bounds are permitted. |
| DateOnly | DateOnly calendar date. |
| DateTime | DateTime instant. |
A numeric literal or an ordinary Number path is not a valid bound for a date
control, even when its digits match that date's wire representation. A fixed
DateOnly bound is a DateOnly literal, such as
d"1990-01-01". A
DateOnly result becomes its valid yyyyMMdd boundary value and a DateTime
result becomes integral epoch seconds only at the range boundary. Expressions
retain their semantic types.
null means that no dynamic bound is currently available, including when the
first request is composed before any reference context exists. It adds no constraint.
A non-null Boolean, String, array, collection, object, mismatched semantic
type, or non-finite numeric result is an error. When an expression's result
cannot be proved compatible statically, clients MUST check it at runtime and reject
validation on an incompatible result. They MUST NOT silently remove the bound,
erase its semantic type, or coerce the result to a Number.
For example, @LibertasRangeMax('[] <= 100') is rejected because its result is
Boolean. Use @LibertasRangeMax('100') for a numeric maximum, or put the
Boolean predicate in ValidationRules with its required message key. Paths
and arithmetic expressions that preserve the required type are supported; bounds are not restricted to literal constants. For example, a
DateTime path plus an integer number of seconds remains a DateTime bound;
subtracting two DateTime paths yields a Number and cannot bound DateTime.
An active RangeMin is an inclusive lower bound and an active RangeMax is an
inclusive upper bound. They intersect the native numeric type limits and any
static NumberMin / NumberMax constraints. Clients MUST apply that same
intersection to final validation and to numeric, DateOnly, DateTime, and TimeInterval
GUI controls. A changing dependency value MUST update the live GUI constraint.
DateOnly bounds use the yyyyMMdd wire representation at the schema boundary,
but Data Validation evaluates the value as a real Gregorian calendar date.
DateTime remains a distinct UI/wire and expression type but is
constraint-backed as an unsigned integral Unix timestamp. Static Number constraints use
that wire representation; dynamic Range expressions require a DateTime result.
Their intersection applies to both its local date/time chooser and final UTC
epoch-seconds validation.
RangeMin and RangeMax do not change the NumberStep origin or lattice. If
the active effective minimum exceeds the active effective maximum, the range
is unsatisfiable; a client MUST NOT swap the two bounds or otherwise invent a
valid interval.
A numeric field, including an editable request field, can use limits from the current reference context. When its reference objects do not provide them, these expressions return null and add no dynamic bound; ordinary numeric constraints still apply:
/** Measured value
* A numeric result constrained by the available server-provided limits.
* @LibertasNumber(type=f64)
* @LibertasRangeMin('{$.limits.minimum}')
* @LibertasRangeMax('{$.limits.maximum}')
*/
value: number;
These curly references can directly constrain editable request fields. The client reads the already received response; it does not fetch server state. The server remains responsible for enforcing its current authoritative limits.
4. Path Access
4.1 Syntax
Current-data references use the square-bracket expression form of Libertas Schema Path. Its canonical specification defines field/index syntax, owner roots, cursors, array fan-out, result order, and null/collection conversion.
4.2 Path Resolution
Apply the Libertas Schema Path resolution rules to the actual current data instance. The worked examples below illustrate those rules in validation expressions; they do not define another path language.
4.3 Struct Access
Given:
struct User {
string name;
}
and concrete data:
user.name = "John"
then:
[user/name]
evaluates to:
"John"
4.4 Null Values
Every field uses ordinary equality operators for its current editor value, including a required field that has not received input yet.
[email] == null
[email] != null
No special nullable-access syntax exists.
The comparison is valid even when the schema field is not Nullable. Client
editors use null as the initial state of many required fields, and rules are
allowed to describe that state before final structural validation.
For:
[address] == null
the deeper path:
[address/city]
also evaluates to:
null
4.5 Reference paths
Curly expressions such as {$.preferred.path,$.fallback.path} use the
shared path contract for their syntax, lookup context, and
availability. During static rule checking their result is schema-unknown and
nullable. The first-composition guard is illustrated in §20.8.
5. Union Types
A schema MAY define a union:
A | B | C
Every concrete union value has one active runtime type.
Example:
struct A {
int x;
}
struct B {
string y;
}
A | B item;
5.1 is Operator
The is operator tests the selected Union variant or the selected Enumeration
member of a value.
[item] is A // Union: the selected variant is A
[item] is B
[class] is Five // Enumeration: the selected member is Five
Its result is boolean. On a Union it tests the actual runtime variant. When the
operand is an Enumeration, Name is a member name and the test is true exactly
when the stored value selects that member, resolved as the Enumeration itself
resolves a stored value (a member's Fixed value first, otherwise its index).
A null, unset, or unresolvable Enumeration value gives false. A numeric
comparison such as [class] == 0 is unaffected.
Studio rejects a Name that cannot match when the operand's type is known: on
an Enumeration, a name that is not one of its members; on a Union, a name that
no variant can ever match. [s/distribution] is Patronage therefore also
works as a collect filter.
Formally:
[ x\ is\ T = \begin{cases} true & \text{if }x\text{ has runtime type }T\ false & \text{otherwise} \end{cases} ]
If the left operand is null:
null is T
evaluates to:
false
Negation uses the standard ! operator:
!([item] is A)
5.2 Path Access Through Unions
Libertas Schema Path follows the active Union payload.
For item = A { x: 10 }, [item/x] returns 10 and [item/y] returns null when
y belongs only to another variant. This uses the same absence rules as missing
fields and indices; no special Union-access syntax is introduced.
6. Array Access
6.1 Explicit Index
The Libertas Schema Path index form selects array elements, for example:
[items/0]
[items/0/name]
If the index does not exist, the path resolves to null.
6.2 Cursor traversal
all, any, none, and collect bind a cursor to each concrete element of
the arrays selected by their source path. A cursor is the element node itself,
not its array index:
all(item in [items],
[item/value] >= 0
)
[item] denotes the bound node and [item/value] starts path resolution from
that node. A cursor always retains the concrete absolute path used to reach its
node, so the example's second element is rendered diagnostically as
[items/1/value].
A source path MAY cross other arrays before reaching its terminal arrays. When a named segment remains and the current node is an array, the same segment is resolved from every element. The terminal arrays are concatenated in path and element order, then their elements are bound in that order:
all(item in [groups/items],
[item/value] >= 0
)
Here [groups/items] selects every items array under every element of
groups. No index variable is needed, and there is no ambiguous index owner.
The former index-binding form, such as
all(i in [items], [items/i/value] >= 0), is invalid. There is no compatibility
interpretation for it.
7. Boolean Semantics
7.1 Boolean Context
Boolean values evaluate normally:
true -> true
false -> false
null is implicitly converted to boolean false:
[ Bool(null)=false ]
Therefore:
[enabled]
is valid when [enabled] has type:
bool
or:
bool?
If [enabled] is null, it evaluates as false.
7.2 No General Truthiness
Non-null values other than booleans MUST NOT be implicitly converted to boolean.
The following are not valid boolean expressions merely because their values are non-null:
[count]
[name]
[object]
[array]
Explicit comparison MUST be used:
[count] != null
[name] != null
[object] != null
[array] != null
Thus the only boolean coercion defined by this specification is:
bool -> itself
null -> false
7.3 First-non-null value fallback
Single | is a value-preserving, left-to-right null-fallback operator:
expr1 | expr2 | expr3
The evaluator returns the first operand whose value is not null, without
changing that value's type. It evaluates no later operand after finding a
winner. false, numeric zero, an empty string, an empty array or collection,
and an object are all non-null and therefore win. The result is null only
when every operand evaluates to null.
([preferredLimit] | [legacyLimit] | 100) >= 0
[preferredEnabled] | [legacyEnabled] | false
The operator may produce any supported value type inside an expression, but the complete rule expression must still be Boolean. There is no additional truthiness conversion: a numeric, string, array, collection, or object result must be used with an explicit comparison or another type-appropriate operator.
The result's possible static types are the left operand's non-null types plus the right operand's possible types only when the left operand may be null. A chain is left-associative and applies that rule repeatedly.
8. Logical Operators
The language provides C-style logical operators:
!
&&
||
Precedence, from highest to lowest:
!
&&
||
&& and || MUST use short-circuit evaluation.
Example:
[address] != null &&
[address/city] != null
If [address] is null, the second expression need not be evaluated.
Likewise:
[item] is A &&
[item/x] > 0
9. Equality Operators
The language provides:
==
!=
null is an ordinary value for equality comparison.
[x] == null
[x] != null
The following identities apply:
null == null -> true
null != null -> false
value == null -> false
value != null -> true
for every non-null value.
Non-null equality uses internal semantic types. Integer and floating-point Numbers are compatible with each other. DateOnly equals only DateOnly and DateTime equals only DateTime; neither equals a Number merely because its wire value is numeric.
10. Relational Operators
The language provides:
<
<=
>
>=
Examples:
[age] >= 0
[min] <= [max]
[value] >= [min] && [value] <= [max]
Operands MUST be compatible according to the schema's type system.
Ordinary integers and floating-point Numbers are mutually comparable. DateOnly is comparable only with DateOnly, and DateTime only with DateTime; their storage encodings do not make them comparable with a Number. Strings are comparable only with strings.
A relational comparison involving null evaluates to false.
For example:
[item/x] > 0
evaluates to false if [item/x] resolves to null.
11. Arithmetic Operators
The core language provides:
+
-
*
/
%
and unary:
+
-
Example:
[end] - [start] <= [maximum_duration]
Numeric literals and decoded scalar decimal text are evaluated as exact rational values. Addition, subtraction, multiplication, division, remainder, equality, and ordering preserve the low digits of i64 Money and do not use binary64 intermediates. Division does not round a repeating fraction; remainder uses a quotient truncated toward zero. Each reduced numerator and denominator is limited to 256 decimal digits. Arithmetic exceeding that limit produces null, as does division by zero. This evaluation representation does not change the schema's integer/floating semantic types or any wire encoding. Ordinary numeric UI controls may still use floating-point coordinates; Money bounds and final checks retain exact values throughout.
Ordinary integer and floating-point operands use numeric arithmetic. Integer
results remain internally typed as integers except that / produces a Number;
an operation involving a floating-point Number produces a Number.
DateOnly and DateTime have these additional, exhaustive arithmetic overloads:
DateOnly + integer -> DateOnly, shifted by Gregorian calendar days
integer + DateOnly -> DateOnly, shifted by Gregorian calendar days
DateOnly - integer -> DateOnly, shifted by Gregorian calendar days
DateOnly - DateOnly -> Number, elapsed calendar days
DateTime + integer -> DateTime, shifted by Unix seconds
integer + DateTime -> DateTime, shifted by Unix seconds
DateTime - integer -> DateTime, shifted by Unix seconds
DateTime - DateTime -> Number, elapsed seconds
The integer operand MUST be internally typed as an integer; an integral value
whose declared or derived type is floating point is not sufficient. DateOnly
arithmetic MUST use Gregorian calendar rules: a DateOnly path carrying the wire
value 20240228 plus 1 evaluates to DateOnly 20240229, and plus 2
evaluates to DateOnly 20240301. It MUST NOT add to the numeric yyyyMMdd
encoding. DateOnly/DateTime mixed
arithmetic, integer-minus-date, unary +/- on a date, and *, /, or %
on a date are invalid. Calendar-month and year shifts and the extraction of a
date's year, month, and day are the functions of section 11.1, not operators.
If an arithmetic operand evaluates to null, the arithmetic result is null.
Therefore:
[a] + [b]
evaluates to null if either operand is null.
An undefined arithmetic operation, such as division by zero, also evaluates to null.
This keeps expression evaluation total for schema-compliant data.
11.1 Calendar-date functions
year(date)
month(date)
day(date)
add_months(date, months)
add_years(date, years)
These functions read and shift the Gregorian calendar fields of a DateOnly
value. date MUST have semantic type DateOnly: a DateOnly path, a
DateOnly literal, or a fallback, function, or
arithmetic result of that type. A Number, including one whose digits spell a
yyyyMMdd date, a String, a collection, and a DateTime are static errors
(section 16). DateTime is excluded because an instant has no calendar date
without a time zone, and rules have no time-zone input.
-
year(d)returns the calendar year (1000 through 9999),month(d)the month (1 through 12), andday(d)the day of the month (1 through 31), each as an integer Number. For example,year(d"2024-02-29") == 2024,month(d"2024-02-29") == 2, andday(d"2024-02-29") == 29. -
add_months(d, n)returns the DateOnlyncalendar months afterd, or before it whennis negative. The year and month advance bynmonths. The day of the month is kept when the target month has that day; otherwise it becomes the target month's last day:Cadd_months(d"2024-01-31", 1) == d"2024-02-29" add_months(d"2023-01-31", 1) == d"2023-02-28" add_months(d"2024-03-31", -1) == d"2024-02-29" add_months(d"2024-05-31", 1) == d"2024-06-30" add_months(d"2024-01-15", 24) == d"2026-01-15" -
add_years(d, n)isadd_months(d, 12 * n), soadd_years(d"2024-02-29", 1) == d"2025-02-28"andadd_years(d"2024-02-29", 4) == d"2028-02-29".
The count n MUST be internally typed as an integer, as for the day
arithmetic of section 11; a floating-point Number is a static error even when
its value is integral. Because of the day clamp, shifts do not compose:
add_months(add_months(d"2024-01-31", 1), 1) is d"2024-03-29", while
add_months(d"2024-01-31", 2) is d"2024-03-31". A shift by days remains the
+ and - arithmetic of section 11.
If any argument is null, the result is null. A shift whose result falls
outside the DateOnly range of years 1000 through 9999 also evaluates to null,
as does day arithmetic beyond that range. The functions are pure (section 17):
they read only their arguments, and the language has no current-date value.
The language has no month-difference function. A whole-month difference has
competing conventions at month ends; compare a shifted date instead. For
example, "at least n months from a to b" is add_months([a], n) <= [b],
and the last day of a period of n months beginning on [start] is
add_months([start], n) - 1.
12. Collection Operations
Traversal sources in this section MUST be data paths that resolve only to
arrays. Arrays selected through multiple parent-array elements are concatenated
in deterministic path and element order. A selected null or missing array
contributes no elements.
12.1 all
all(node in [array_path], expression)
Returns true iff the expression evaluates to boolean true for every array element.
For an empty array:
all(...) -> true
Example:
all(item in [items],
[item/price] >= 0
)
12.2 any
any(node in [array_path], expression)
Returns true iff the expression evaluates to boolean true for at least one array element.
For an empty array:
any(...) -> false
12.3 none
none(node in [array_path], expression)
Returns true iff the expression evaluates to false for every array element.
For an empty array:
none(...) -> true
12.4 collect
collect(node in [array_path], predicate, projection_expression)
collect returns an ordered collection. Its source remains a current-data
path selecting arrays, including arrays reached through parent-array fan-out.
For each concrete cursor, it evaluates predicate first. Only Boolean true
causes the projection to be evaluated, once, in that cursor's scope. False or
null predicates contribute nothing and do not read the projection.
The projection accepts any expression in this language:
- A direct square data path retains path-selection behavior: append every selected node in path order, flattening arrays crossed on the path. A missing field selects no node; a selected null field contributes one null. A path ending at a List contributes that List as one value, without flattening its elements.
- Another expression producing a collection, including nested
collect, concatenates its items in order at this one boundary. An empty collection contributes no item. - Another expression producing a scalar, null, object, or List contributes exactly one value, retaining its semantic type and identity. A computed null is one item, even when it came from a missing path. Objects and Lists are not recursively flattened or coerced.
Thus collect(row in [rows], true, [row/maybe]) skips missing maybe fields,
whereas collect(row in [rows], true, [row/maybe] | null) includes a null item
for each missing field. Existing null and arithmetic rules still apply.
The result preserves source order, projection order, and duplicates; it is a
collection (bag), not a mathematical set. Empty sources and predicates that
never evaluate to true produce empty collections.
Filtering before projection permits Union-specific paths:
collect(node in [nodes],
[node] is Active,
[node/device]
)
Only an Active concrete Union variant is projected. Other variants need no
device field and contribute nothing. Studio narrows projection validation
using positive type tests on the cursor or a nested cursor path. The tests may
be conjuncts of &&; unrelated conjuncts do not remove their guarantees.
For example, [row/document] is Payment && [row/enabled] permits numeric
projection paths through the selected Payment payload even when another
variant declares an object field with the same name. A deeper path uses the
most specific compatible guard, and repeated guards on one path intersect.
Contradictory guards with no compatible declared branch are rejected.
These facts apply only to that collect projection and its nested scopes;
they do not change the schema or escape to later expressions. ||, negation
and fallback do not establish positive type facts. A guard on another cursor
or path cannot narrow this one, and array fan-out remains invalid as an is
operand. Null and absent payloads retain their existing evaluation behavior.
Computed numeric projections can be summed without storing derived totals:
sum(collect(row in [rows], [row/enabled],
([row/amount] | 0) * [row/count])) <= [maximum] : "TOTAL_ABOVE_MAXIMUM";
Nested projections can filter by an outer cursor before aggregating:
sum(collect(group in [groups], true,
sum(collect(item in [group/items], [item/year] == [year],
[item/amount])))) <= [maximum] : "YEAR_TOTAL_ABOVE_MAXIMUM";
Cursors remain lexical node bindings. Inner traversals may read outer cursors; they cannot reuse a name already in scope or leak a name outside their predicate/projection. Nested calls and quoted commas use the ordinary expression grammar and string escape rules; this feature adds no quoting or escape layer. Predicate, Boolean, and first-non-null short-circuit behavior, validation-phase behavior, and null behavior remain unchanged.
Computed projections require clients implementing this contract. Older clients support only direct data-path projections and must be updated before an App using computed projections is distributed to them. Schema acceptance alone does not update an installed client.
12.5 is_unique_set
is_unique_set(collection)
Returns true exactly when no two collection elements compare equal under the
ValidationRules == semantics. Empty and single-element collections are
unique. Two null elements are duplicates.
A fan-out path argument such as [rows/key] is always the collection of the
nodes it selects, whether that is zero, one or many, including zero when a List
on the path is null; it is never reduced to null or a single value for this
function.
all is not overloaded for this purpose. Its predicate is evaluated
independently for one bound cursor and returns only a Boolean; it has neither
collection state nor another-element binding with which to detect duplicates.
The canonical Union-filtered uniqueness rule is:
is_unique_set(
collect(node in [nodes],
[node] is Active,
[node/device]
)
) : "ACTIVE_DEVICE_MUST_BE_UNIQUE";
A key of several values uses the cursor form:
is_unique_set(cursor in [array_path], key_1, key_2, ...)
The cursor binds to each element of the source arrays exactly as for all
(section 6.2), and the key expressions are evaluated for that element in
order. The element's key is the tuple of their values. The result is true
exactly when no two elements have keys whose corresponding components all
compare equal under ==. A null component therefore equals only null:
two elements that agree on every other component and are both null in one
component are duplicates, while a null and a non-null component differ. An
empty source, a single element, and a missing or null source array are
unique (section 12.7). At least one key is required. Each key MUST produce one
value per element, not a collection; it is typically a path from the cursor
and MAY be any other expression, such as year([row/date]).
is_unique_set(row in [carrybacks], [row/credit], [row/year]) : "CARRYBACK_DUPLICATE";
The keys are bound to their element rather than written as separate paths: independently resolved paths form separate collections, and a field absent from one element's active Union payload would shift the later values, pairing components that belong to different elements.
12.6 count
count([array])
returns the number of elements in an array or a collect result.
Example:
count([items]) >= 1
A fan-out path argument such as [rows/key] is always the collection of the nodes it selects, so it counts 0, 1 or more (0 when a List on the path is null); otherwise, if its argument resolves to null, count() evaluates to null.
12.7 Missing or Null Traversal Arrays
A missing or null terminal array contributes zero elements to a traversal.
Consequently, the usual empty-collection identities apply: all and none
return true, any returns false, and collect returns an empty collection.
Use an explicit path null test when array presence is itself required.
12.8 sum
sum(collection)
returns the sum of the Numbers its argument selects. The argument is a
current-data path that fans out across one or more arrays, such as
[rows/amount], a collect result, or a path to an array of Numbers. Every
leaf it can select or compute MUST be an ordinary integer or floating-point Number,
including Money. A DateOnly, DateTime, String, Boolean, object, or array leaf,
a path that selects one scalar without fan-out, and a direct reference path are static
errors (section 16). Studio retains the semantic item type of computed
collections, even when an item has no schema node: numeric expressions and
date differences can be summed; dates shifted by integers remain dates and
cannot. An all-null computed projection sums to integer zero. An unresolved
reference type does not establish ordinary numeric leaves.
null leaves are skipped. When nothing is selected, including an empty or
missing array or a source whose leaves are all null, the result is the
integer 0; a single selected value is its own sum. The sum is therefore a
Number for every data instance, and a rule such as
[principal] <= sum([contributions/amount]) needs no null guard for the
collection. Addition is the exact arithmetic of section 11, so the result is
independent of order; it is null only when it exceeds that section's exact
numeric limit. The result is an integer when every summed leaf is
integer-typed, and a Number otherwise.
A collect predicate sums a filtered subset, here the deposits whose Boolean
later field is true:
sum(collect(deposit in [deposits], [deposit/later], [deposit/amount])) <=
sum([deposits/netAmount]) : "LATER_DEPOSITS_ABOVE_NET";
13. String Operations
The core specification defines:
length(string)
which returns the number of bytes in the string's UTF-8 encoding. Every platform
must use this same byte count, matching
String SizeMin/SizeMax.
The text is measured as supplied, without Unicode normalization. For example,
length("A") is 1, length("é") is 2, length("中") is 3,
length("😀") is 4, and length("e\u0301") is 3.
Native-character constraints (MinLengthAbs/MaxLengthAbs) remain a separate
Unicode-scalar constraint.
length counts bytes, not characters, by design. A byte bound is the same on
every platform and matches what storage, wire messages and native buffers
actually hold, so a value accepted on one client can never exceed a limit on
another. A character count differs by platform and Unicode version. The language
has no character-counting function today. If a rule ever needs one, it will be a
separate, explicitly named function; length and String Size will keep counting
UTF-8 bytes.
Example:
[name] != null &&
length([name]) > 0
If the input is null:
length(null) -> null
The full-string matches predicate is defined in §13.2 below.
13.1 String resource references
resource([status])
takes a FormattedText value and returns its string resource key as a
String, or null when the value is null. The key identifies the resource
template, not its localized text, so comparing it gives the same result in
every locale. Arguments do not change the key. Studio rejects resource(...)
on a value that is not FormattedText, and a string literal compared with a
resource(...) result that is not a key the package's StringResources
declares.
resource([status]) == "STATUS_NOT_STARTED" || resource([status]) == "STATUS_NONE"
13.2 Full-string patterns
matches(value, pattern) tests the entire String against the portable regex
language below. It returns Boolean for two Strings, without trimming,
normalization, substring search, flags, captures, replacement or coercion.
An empty pattern matches only empty text.
Exactly two arguments, each a String expression or null; current/reference paths and source constants use existing expression contexts. Studio rejects Number, dates, Boolean, collections and FormattedText arguments. There is no coercion, translation lookup or general truthiness.
With two Strings, return Boolean. Empty text matches only a pattern accepting
empty text; an empty pattern accepts only empty text. A null argument returns
null under existing missing-data/deferral rules. Optional fields can use
[] == null || matches([], "[0-9]+"). Complete final rules still require true.
Studio compiles every statically known pattern, even in an unreachable branch. Malformed syntax, unsupported constructs and compilation limits produce a schema error at the narrowest source location.
Runtime evaluation is ordered: evaluate arguments left-to-right and propagate
argument errors; reject non-null wrong types or ill-formed scalar Strings;
return null if either remaining argument is null; otherwise compile and match.
An invalid dynamic pattern with a null value therefore returns null before
compilation, while a statically known bad pattern is always rejected by Studio.
With two non-null Strings, compilation precedes any empty-text shortcut.
A dynamic pattern compiles when evaluated. Invalid patterns, wrong runtime
types and compilation failures are evaluation errors, not false or null.
An evaluated error makes the enclosing rule fail, including under !, |
or ||. Short circuiting still avoids evaluating an unused branch. Thus
!matches(value, badPattern) cannot pass. This explicit error distinction
must be preserved through the evaluator; the existing missing-value sentinel
alone is insufficient. No expression-level exception-catching API is added.
Text-free presentation conditions reject matches. Its Boolean result cannot serve as RangeMin/RangeMax. Business validation never enters Display Template.
Pattern attribute and lifecycle
Pattern has one String value per physical node. It is valid on an effective String field, String list element or named type resolving to String, including nullable String. It is invalid on packages, functions, Endpoints, containers, Enumerations, Number/date nodes and Bytes/FormattedText. Annotate a String payload rather than its containing Union variant. It does not recurse.
Use the existing effective-attribute resolver: the field's explicit Pattern wins over its referenced type's Pattern. Existing cumulative ValidationRules remain cumulative. Studio validates placement, multiplicity, regex grammar and materialized effective Default/Fixed values. Non-null defaults/fixed values must match; allowed null remains governed by Nullable/mandatory rules.
Clients run Pattern through ordinary intrinsic value validation alongside Size, on committed edits and final submission. Temporary incomplete typing may remain editable but cannot be submitted. Import, copy, calculation, restore, Hidden and ReadOnly do not waive validation or erase bad values. Null adds no pattern mismatch; ordinary requiredness still applies.
A mismatch uses a standard localized message, such as “Enter a value in the required format.” The field description explains that format. There is no second message-key attribute; use matches in ValidationRules for a custom message. Invalid patterns are configuration/rule errors, not typing errors.
Pattern stays semantic schema and affects the ordinary revision digest. String wire/storage encoding is unchanged. The Hub preserves the attribute; there is no new message field, editor, history or persistent regex cache. Server Apps still validate untrusted requests independently.
Portable regex language
One regular-language grammar over Unicode scalars, case sensitive and locale independent. No normalization: composed and decomposed accents differ.
| Construct | Meaning |
|---|---|
| Literal scalar | That scalar, unless syntax punctuation below |
Concatenation; A|B |
Concatenation; union of alternatives, including empty alternatives |
(A), (?:A) |
Grouping; no exposed captures or references |
[abc], [^abc], [a-z] |
Set, scalar complement, inclusive scalar range |
?, *, + |
Zero/one, zero/more, one/more of the preceding atom/group |
{m}, {m,}, {m,n} |
Nonnegative decimal repetition counts; m<=n |
. |
One scalar except LF, CR, U+2028, U+2029 |
^, $ |
Absolute start/end; no “before final newline” meaning |
\d, \D |
ASCII [0-9], its scalar complement |
\w, \W |
ASCII [A-Za-z0-9_], its scalar complement |
\s, \S |
ASCII space, tab, LF, CR, form feed, vertical tab; complement |
\n \r \t \f \v |
Those control scalars |
\xHH \uHHHH \u{H...H} |
Exactly 2/4/1–6 hex digits; valid scalar <=10FFFF, not D800–DFFF |
\\ \. \^ \$ | \? \* \+ \( \) \[ \] \{ \} \- |
Escaped literal punctuation |
The complete literal-punctuation escape set, without Markdown table escaping:
\\ \. \^ \$ \| \? \* \+ \( \) \[ \] \{ \} \-
Within a class, only leading unescaped ^ negates. Unescaped - is literal
first/last, otherwise a range separator between single-scalar endpoints.
Literal [ and ] must be escaped. Other syntax operators are literals in a
class. Predefined sets can be class members, never range endpoints. Reject
empty/nested/unterminated classes and reversed ranges. && inside a class
means literal ampersands, never host-engine intersection.
Outside classes, . ^ $ \ | ? * + ( ) [ ] { } are syntax punctuation;
unmatched closing punctuation is invalid. Quotes, apostrophes, slash, hyphen,
hash, semicolon, colon, @ and spaces are ordinary regex literals. Literal
control scalars are allowed and match themselves. A quantifier requires a
complete preceding atom/group; quantifying anchors, repeated quantifiers,
signed counts, count whitespace and malformed braces are invalid.
A scalar escape produces one literal atom even when that scalar is syntax
punctuation; it is not lexed again. Unpaired surrogates in a decoded pattern
or evaluated value are errors rather than partial scalar matches.
Unknown regex escapes are errors, including \q \0 \1 \b \p \/. Write
quotes/slash directly and use the stated control/scalar escapes.
\uD83D\uDE00 is invalid regex scalar notation; use literal emoji or
\u{1F600}. No flags, embedded modes, named groups, backreferences,
lookaround, property categories, lazy/possessive quantifiers, atomic groups
or class set operations. Slash pairs are ordinary text, not removed delimiters.
Hex digits are ASCII 0–9/A–F/a–f. Fixed-width \xHH/\uHHHH consume
exactly their stated digits; following scalars begin the next atom. Braced
Unicode escapes require 1–6 digits and a closing brace, without signs or
whitespace. Incomplete, out-of-range and surrogate escapes, or a trailing
unpaired backslash, are errors. These escape rules are identical inside and
outside classes; a decoded -, ] or ^ is a literal class member, never
reinterpreted as class structure.
Require non-backtracking regular-language matching, such as Thompson NFA, including for nested/ambiguous repetition. Fixed compilation limits: 4096 pattern scalars, 64 group nesting, repeat endpoints <=10000 and at most 65536 compiled states. Over-limit patterns are errors, never truncated. Match work is bounded by input scalars times states; ordinary String/schema/runtime limits still bound input storage. No timing-dependent accepting fallback.
Use one compiler/matcher per client for both interfaces. Native engines can be reused only if an adapter proves the grammar, scalar/anchor behavior and non-backtracking guarantee. Calling JS RegExp, Android Pattern and NSRegularExpression directly does not establish those properties.
Escape and quote contract
Decode each layer once. The compiler receives regex text, not source code or a JSON-encoded value. Transport JSON escaping is representation only.
At each cooked-literal or quoted-annotation boundary, a quote closes only when preceded by an even number of consecutive backslashes (including zero). An odd number escapes it. Process escapes left-to-right; checking only the immediately preceding character is insufficient. Raw Rust literals instead end at their exact quote-plus-hashes delimiter. After native source decoding, there is no additional HTML entity/URL decoding, interpolation, Unicode or whitespace normalization, or second unescape.
Source attribute layer
For shared semantic rejection, follow the Pattern placement and grammar requirements.
Rust: one positional native String literal, cooked or raw, or $CONST
resolving to a native String through existing source-constant machinery.
Cooked literals use native Rust escapes; raw r"...", r#"..."# etc do not
decode backslashes. Increase delimiter hashes when content includes the
current closing quote/hash sequence.
Native Rust source preprocessing and cooked-string line continuations retain
their native meaning; they are not regex escapes. For exact LF/CR data, prefer
explicit cooked escapes or regex \n/\r in a raw literal. If the content
cannot fit a legal raw delimiter, use a cooked literal instead. Bytes and
character literals are not accepted as String arguments.
For shared semantic rejection, follow the Pattern placement and grammar requirements.
TypeScript Pattern in JSDoc: one positional double-quoted JSON String
literal, or $CONST resolving a native String constant. Decode JSON once,
without trim. Accept exactly JSON escapes \" \\ \/ \b \f \n \r \t \uHHHH;
surrogate pairs form one scalar. An unpaired surrogate in decoded regex text
is a shared Studio semantic error; native syntax decoding does not replace it. Reject
single/backtick quotes, unknown escapes, raw controls, incomplete escapes,
extra/named arguments and trailing non-whitespace before ). Outside
whitespace is permitted; inside spaces are significant.
JSON whitespace here means only space, tab, LF and CR. Raw U+0000–U+001F inside the JSON literal is invalid; use an escape. Literal Unicode scalars above that range, including U+2028/U+2029, are JSON content, not delimiters. Native source/comment syntax must also permit the spelling. No implicit line continuation or JSDoc line-decoration removal occurs inside a literal; use escaped controls or a constant for multiline patterns.
This is the grammar for the Pattern JSDoc annotation, not an assertion that
JSDoc is a JavaScript expression. Native constants decode in their native language
before resolution.
For shared semantic rejection, follow the Pattern placement and grammar requirements. $CONST occupies the whole argument; no interpolation
occurs inside quoted patterns, where $ remains a regex anchor.
Scan delimiters while respecting quoted strings and escapes: a regex ),
@ or brace inside a String cannot terminate the annotation. JSDoc */ still
ends its comment; represent a literal star/slash sequence using JSON *\/
or a native constant. HTML entities have no decoding role. JSON \b is U+0008,
not a regex boundary; JSON has no \u{...} escape, so double that backslash.
This applies to annotation discovery as well as argument extraction:
@LibertasPattern("@LibertasHidden()") declares only Pattern. Its contents
must never create Hidden or another annotation. A marker is recognized only
outside an already recognized annotation's quoted argument. Separate adjacent
annotations resume normal scanning after the closing ).
ValidationRules literal layer
The existing rule lexer permits single and double quotes; the matching
unescaped quote closes. A backslash followed by n/r/t emits LF/CR/tab; followed
by any other character, it emits that character and drops the backslash.
Thus \\ emits one backslash, \" quote and \' apostrophe. Missing closing
quote or dangling escape is a syntax error. These identity escapes remain
part of the rule literal grammar.
In particular, rule \b \f \v \0 decode to ordinary b/f/v/0, and a
backslash followed by an actual line break preserves that line break rather
than joining lines. Raw controls are accepted by the existing rule lexer;
their enclosing source representation must still be valid. To request regex
form-feed/vertical-tab escapes, transmit \\f/\\v. For NUL or backspace,
transmit regex \\x00/\\x08 or an actual scalar; regex \0/\b are
unsupported regardless of their meaning in a host regex engine.
Double every backslash intended for regex: rule "\\d+" transmits regex
\d+, whereas "\d+" transmits d+. Similarly "\\q" transmits an invalid
regex escape, while "\q" transmits ordinary q. Rule \u/\x are identity
escapes: write "\\u{1F600}" to transmit that regex Unicode escape.
An outer Rust ValidationRules literal decodes first; prefer raw Rust. Existing TypeScript LibertasValidationRules JSDoc preserves raw quoted annotation content, unlike the new JSON-decoded Pattern. Use a single-quoted outer envelope and double-quoted rule strings:
#[libertas_validation_rules(r#"matches([], "\\d+") : "DIGITS";"#)]
pub digits: String,
/** @LibertasValidationRules('matches([], "\\d+") : "DIGITS";') */
digits: string;
For the old TypeScript annotation, an apostrophe inside a double-quoted rule String must also survive the single-quoted annotation envelope. For example:
/** @LibertasValidationRules('matches([], "[\"\']+") : "QUOTES";') */
The outer annotation preserves both backslash/quote sequences; the rule lexer
then decodes them to regex ["']+. Ordinary TypeScript program constants
instead use native TypeScript string decoding. Do not confuse a JSDoc
annotation envelope with a program String literal.
The earlier Rust and TypeScript digit examples transmit the same regex. Do not add another source-unescape pass to the existing TypeScript ValidationRules annotation as an incidental change.
For a mechanical rule-literal encoder, choose double quotes and escape every
backslash as \\, both quote kinds as \" and \', and LF/CR/tab as
\n/\r/\t. Other scalars remain unchanged. Escaping the apostrophe
is redundant for the rule's double-quote delimiter but makes the same text
safe inside the legacy TypeScript single-quoted envelope. Preserve that rule
text verbatim in the envelope; do not apply JSON or JavaScript string encoding
to it. JSDoc's physical */ restriction still applies to the whole comment,
including the legacy envelope. A regex slash can instead be spelled \x2F
(double its backslash in the rule). Native constants use their existing
annotation-specific resolution; they do not acquire interpolation semantics.
Regex layer and exact-value examples
The regex compiler never re-decodes JSON, source strings, rule literals, constants or HTML. A String obtained from a path already contains decoded text.
| Regex text intended | Rust Pattern argument | TypeScript Pattern argument | Rule pattern literal |
|---|---|---|---|
\d+ |
r"\d+" or "\\d+" |
"\\d+" |
"\\d+" |
\\, matches one backslash |
r"\\" or "\\\\" |
"\\\\" |
"\\\\" |
\., matches dot |
r"\." |
"\\." |
"\\." |
["']+, matches quotes |
r#"["']+"# |
"[\"']+" |
"[\"']+" |
\u{1F600} |
r"\u{1F600}" |
"\\u{1F600}" |
"\\u{1F600}" |
\n, matches LF |
r"\n" |
"\\n" |
"\\n" |
| Actual LF scalar | "\n" |
"\n" |
"\n" |
| Space-a-space | r" a " |
" a " |
" a " |
Regex \d+ serializes as {"Name":"Pattern","Value":"\\d+"}; the decoded
Value contains one backslash, d, plus. Regex matching a literal backslash
serializes as {"Name":"Pattern","Value":"\\\\"}; decoded Value contains two
backslashes. Do not decode either serialized spelling again.
The two Unicode spellings distinguish the layers: TypeScript Pattern
"\uD83D\uDE00" decodes to literal emoji and is valid; Pattern
"\\uD83D\\uDE00" decodes to two regex surrogate escapes and is invalid.
JSON "\b" decodes to a literal backspace scalar and is valid regex text;
JSON "\\b" decodes to the unsupported regex escape and fails. Likewise,
JSON "\/" supplies literal slash; JSON "\\/" supplies invalid regex \/.
No syntax error is repaired or downgraded to a mismatch.
A decoded pattern consisting of one final backslash is malformed regex even
when its source literal is valid. To match one backslash, supply two decoded
backslashes, as above. Escaping a quote at a source boundary does not make it
a regex escape: decoded quotes are ordinary scalars. Unmatched )/{/}
can safely survive source delimiters but still need regex escaping or class
placement to be literal regex content.
Quotes/apostrophes are not regex metacharacters. Escape them only for their
source/rule delimiter or choose a raw delimiter. A backslash before a closing
quote is interpreted by the enclosing literal first; literal final backslash
requires the complete doubling above. Regex ; : @ ) { } inside a quoted
argument cannot end a call, rule or annotation.
14. Expression Grammar
The normative grammar is:
The current_data_schema_path and reference_object_schema_path productions
are defined once in Libertas Schema Path.
rule_set
= [ "FinalOnly" "=" boolean_literal ";" ]
rule_pair
{ rule_pair } ;
rule_pair
= expression ":" string ";" ;
boolean_literal
= "true" | "false" ;
expression
= logical_or ;
logical_or
= logical_and
{ "||" logical_and } ;
logical_and
= equality
{ "&&" equality } ;
equality
= relational
{ ("==" | "!=") relational } ;
relational
= type_test
{ ("<" | "<=" | ">" | ">=") type_test } ;
type_test
= fallback
[ "is" type_name | "has" bitflag_member ] ;
fallback
= additive
{ "|" additive } ;
additive
= multiplicative
{ ("+" | "-") multiplicative } ;
multiplicative
= unary
{ ("*" | "/" | "%") unary } ;
unary
= ("!" | "+" | "-") unary
| primary ;
primary
= literal
| path
| reference_path
| array_operation
| function
| "(" expression ")" ;
path
= current_data_schema_path ;
reference_path
= reference_object_schema_path ;
array_operation
= "all" "(" identifier "in" path "," expression ")"
| "any" "(" identifier "in" path "," expression ")"
| "none" "(" identifier "in" path "," expression ")"
| "collect" "(" identifier "in" path "," expression "," expression ")"
| "count" "(" expression ")"
| "sum" "(" expression ")"
| "is_unique_set" "(" expression ")"
| "is_unique_set" "(" identifier "in" path "," expression
{ "," expression } ")" ;
function
= "length" "(" expression ")"
| "resource" "(" expression ")"
| ( "year" | "month" | "day" ) "(" expression ")"
| ( "add_months" | "add_years" | "matches" ) "(" expression "," expression ")" ;
literal
= integer
| floating_point
| string
| date_literal
| "true"
| "false"
| "null" ;
date_literal
= "d" '"' digit digit digit digit "-" digit digit "-" digit digit '"' ;
type_name (* a Union variant or type name, or an Enumeration member name *)
= identifier ;
bitflag_member
= identifier "." identifier ;
14.1 DateOnly literals
d"YYYY-MM-DD" writes a constant calendar date: the letter d immediately
followed by a double-quoted ISO 8601 calendar date with a four-digit year and
two-digit month and day. The token contains no whitespace or escape. The d
prefix keeps it one token: a bare 2026-01-01 is the subtraction
2026 - 1 - 1, and a plain string literal remains a String.
The literal has semantic type DateOnly and the value of a DateOnly path whose
wire value is YYYYMMDD. Equality, ordering, the date arithmetic of section 11,
|, and a DateOnly RangeMin or RangeMax bound treat the two alike; for
example, d"2024-02-28" + 1 == d"2024-02-29" and
d"2024-03-01" - d"2024-02-28" == 2. Mixing it with a Number or a DateTime is
invalid exactly as for a DateOnly path, so d"2026-01-01" == 20260101 is
rejected. The date MUST be a valid Gregorian calendar date with a year from
1000 through 9999, the DateOnly wire range; d"2026-02-30" and
d"2026-13-01" are static errors (section 16). A literal is a constant; the
language has no current-date value (section 17).
There is no DateTime literal. A written date and time names a different instant in each time zone, and rules have no time-zone input; instants come from DateTime paths and DateTime arithmetic.
14.2 Bitflags membership
has tests whether one flag of a
Bitflags host is
set:
[facts] has PersonFacts.ReleasedToYou
The left operand is a current-data path to a Bitflags host, without array
fan-out. The right operand Catalog.Member is written as one word, with no
whitespace around the .. Catalog is the type name of the catalog that the
host's Bitflags attribute names, without the developer-package:: prefix of
an external catalog, and Member is the published name of one of its nonzero
members, exactly as the catalog declares it: a Rust bitflags constant keeps
its upper-snake name (PersonFacts.RELEASED_TO_YOU), and the examples here use
the neutral spelling of their illustrative catalogs. The
qualifier is required rather than inferred from the host, so the rule reads on
its own and a rule naming a catalog the host does not use is rejected instead of
testing a same-named member of another catalog.
For a non-null host value h and the member's Fixed value m, the result is
the Boolean (h & m) != 0: true when that bit is set, whatever other bits are
set. m is a schema constant, so evaluation stays pure (section 17). A null
host yields null, which is false in Boolean context (section 7.1); the
integer itself is never converted to a Boolean. Bitflags hosts are
non-negative: a negative host value is outside the catalog and the test yields
null, as for a null host. Where absence matters, combine
the test with an explicit null test or a fallback:
[facts] != null && !([facts] has PersonFacts.Student)
([facts] has PersonFacts.ReleasedToYou) | false
has takes one member. Test several flags with && or ||, and the empty
selection with [facts] == 0; the zero member, such as None, is not a flag
and is rejected. Like is, one operand takes at most one has, and negation
needs parentheses: !([facts] has PersonFacts.Student).
15. Operator Precedence
From highest precedence to lowest:
1. ()
2. path access
3. unary ! + -
4. * / %
5. + -
6. |
7. is has
8. < <= > >=
9. == !=
10. &&
11. ||
has shares the is level: both test one value against a schema name and
produce a Boolean, so arithmetic and | bind first, while comparisons,
equality, and logical operators take the result. A fallback for a has result
therefore needs parentheses, as in ([facts] has PersonFacts.Student) | false.
A function call or collection operation is a primary expression: its
parenthesized arguments bind first, so year([start]) + 1 adds one to the year
and add_months([start], 24) - 1 is the day before the shifted date.
Parentheses SHOULD be used when they improve clarity.
Example:
([item] is A && [item/x] > 0) ||
([item] is B && [item/y] != null)
16. Static Rule Validation
Before accepting a schema, Libertas Studio MUST statically verify that:
- operators other than
|are applied to compatible types; |preserves possible operand types and admits its right operand as a result only when its left operand may be null;- every complete rule and every predicate produces
boolor possiblynull; - arithmetic operators apply only to the ordinary numeric and date overloads in section 11, preserving the internal result type;
is Treferences a schema-defined type;- cursor variables are used only as the first segment of a data path and only within their lexical traversal scope;
- every traversal source path resolves only to arrays, including when it selects several arrays by crossing parent arrays;
- a path is potentially resolvable according to at least one schema-valid structural route;
- every
collectpredicate is boolean and its projection is a valid expression in the bound cursor scope, retaining semantic item types and text provenance; positive cursor or nested cursor-path type tests, alone or within&&, narrow projection validation to compatible declared Union branches under section12.4; OR, negation and fallback do not establish such facts; is_unique_setandcountreceive a collection or array, and the cursor form ofis_unique_sethas a traversal source and keys that each produce one value per element (section 12.5);- every rule's string is a declared package
StringResourceskey with a non-empty default-locale message; FinalOnly, when present, is the single leading directive and its value is exactlytrueorfalse;resource(...)receives a FormattedText value, and a string literal compared with its result is a key that the package'sStringResourcesdeclares (section 13.1);- every DateOnly literal has the form of section 14.1 and names a valid Gregorian calendar date in the DateOnly range;
- the left operand of
hasis a current-data path, without array fan-out, that resolves only to Bitflags hosts, and itsCatalog.Membernames the catalog of every such host and one of that catalog's nonzero members (section 14.2); year,month,day,add_months, andadd_yearsreceive a DateOnly first argument, andadd_monthsandadd_yearsan integer count (section 11.1); andsumreceives a fan-out path, acollectresult, or an array whose every possible leaf is an ordinary Number (section 12.8).
Square paths are validated against the attribute evaluation root. Reference paths are syntax-checked but their value type remains unknown and nullable, because the referenced response may use a different compatible schema.
Studio warns when an applicable rule reads a nullable host or path prefix
without explicit absence handling. This includes a named-type rule at a
nullable use and a selected nullable Union payload. Under sections 1, 3 and
4.4, cumulative rules still evaluate with a present null host; descendant
reads yield null and a relational comparison then returns false (section 10).
Absence never makes a rule pass automatically. For an optional record, write
[] == null || ... when the rule should accept its absence.
The diagnostic is a warning, not a schema error: rejecting absence may be intentional. It reports the declaration's source file and one-based line when known, otherwise null. It recognizes explicit null tests, short-circuit null or type guards, and direct path fallbacks. It is conservative, not a proof of validity or a replacement for running supplied fixtures; more complex equivalent expressions may still warn.
A path does not need to apply to every union alternative.
For example, given:
A { int x; }
B { string y; }
A | B item;
both:
[item/x]
[item/y]
are valid rule paths.
Their applicability is determined from the concrete runtime value.
A path that cannot exist under any value permitted by the schema SHOULD be rejected as a rule-definition error.
17. Evaluation Purity
Validation expressions MUST be pure.
A rule MUST NOT:
- mutate data;
- create data;
- delete data;
- perform I/O;
- access unprovided ambient state;
- read the current time from outside the supplied inputs;
- generate nondeterministic values;
- invoke arbitrary executable code.
Reading the supplied referencedData is permitted and does not access
unprovided ambient state. Evaluation does not fetch a response or consult
interaction history.
For fixed schema interpretation, rule, data, and referencedData:
[ Eval(rule,data,referencedData) ]
MUST always produce the same result.
18. Validation Result
The same expression language and evaluation semantics apply when a client
performs incremental validation during editing and when it performs final
validation. There is no separate relaxed or final expression dialect.
Incremental validation reports the rule result for the current, possibly
incomplete datum after omitting declarations whose leading directive is
FinalOnly=true. Final validation evaluates every declaration, including
those final-only rule sets, and combines the result with all applicable static
schema checks.
For schema (\Sigma), concrete or partially entered data, supplied
referencedData, and rule set:
[ R={rule_1,rule_2,\dots,rule_n} ]
define:
[ Result_i(data,referencedData) = Bool(Eval(rule_i,data,referencedData)) ]
For that same referencedData, the data is valid iff:
[ \boxed{ Valid(data,referencedData)= \bigwedge_{i=1}^{n}Result_i(data,referencedData) } ]
Consequently:
every rule true -> VALID
any rule false -> INVALID
Because null becomes false in boolean context:
top-level null -> INVALID
For INVALID, a Libertas client MUST display the failing rule pair's localized
StringResources message. Evaluate applicable cumulative rule sets in their
supplied order and pairs within each set in declaration order; stop at the
first false rule. Skip final-only sets during incremental evaluation. Reporting
the failure MUST preserve normal short-circuit behavior and MUST NOT evaluate
skipped branches, later rules, or later quantifier elements to construct an
explanation.
The supplied message owns the explanation. Clients do not construct, rank, or
render structural failure certificates. If a trusted installed document is
unexpectedly missing the message or supplies only whitespace, report a generic
validation failure; do not expose expressions or entered values as a fallback.
A separate structural diagnostic may still reject a required field that remains
null at submission time.
Let StaticValid include mandatory/nullability, type, range, size, uniqueness,
and every other ordinary schema constraint. These checks receive the same
referencedData where applicable, including dynamic bounds. Final task data
is accepted iff:
[ \boxed{ FinalValid_\Sigma(data,referencedData)=StaticValid_\Sigma(data,referencedData)\land Valid_\Sigma(data,referencedData) } ]
On final validation failure, keep the editor and its data. When the validator identifies a failing field or a rule-owning group, show an error icon and the explanation at that row, expand collapsed ancestors, and scroll the first failure into view. A promoted header uses its owning row; a hidden field uses its nearest visible owner. Do not replace the field’s normal control with an inline editor: Boolean choices, enumerations, Unions and other pickers retain their normal interactions. Function-wide rules without a field owner remain message-only; do not infer field blame from an expression.
The marker is transient client state, not encoded data. After an accepted edit, recheck the marked field and clear its marker when its validation passes or when that field is removed. Do not transfer the marker to a replacement List row or Union branch. A later Submit may reveal the next failure. These local markers do not change backend Error responses, which retain the protocol’s message presentation.
A client Task Editor MUST evaluate every rule set whose effective FinalOnly value is false (including an omitted directive) after every
proposed node change. One synchronous UI action MAY be treated as one atomic
change when it necessarily performs several model mutations, such as selecting
a Union variant and materializing its fields. Node changes include scalar and
byte replacement, nulling or materialization, Union selection, and List
insertion, removal, replacement, or reordering.
The client MUST snapshot the complete editable root set before the change,
because a field-local change can invalidate an ancestor, named-type, or
function rule. If incremental validation fails, the client MUST restore that
snapshot before presenting the violation as a client-native error toast. The
invalid value MUST NOT remain in task data. Declarations whose effective
FinalOnly value is true are not evaluated and cannot reject an incremental change. Incremental validation
does not run mandatory/nullability or other static final checks.
This rollback applies to the edits a user makes, on every client. An edit is rolled back when it introduces a violation: when the first failing rule and its owning node are the same before and after the edit, for example because a calculation result left that rule violated, the edit is accepted, and final validation still blocks submission. A programmatic assignment is not a proposed change and is never rolled back by an incremental rule: a calculation result, a link return, a constrained-choice reset, and a singleton selection stay in the editor, and final validation governs them. A document import validates its complete candidate under its own rule.
The client MUST evaluate all rule sets as part of final validation; a successful
incremental or final rule result cannot override StaticValid=false.
An OutputOnly request node is outside client validation: the client checks neither its static constraints nor the rule sets hosted on it or inside it, incrementally or finally. Rule sets hosted elsewhere that read it evaluate as usual.
19. Constraint Completeness
The schema defines the structurally legal domain:
[ D_\Sigma ]
For fixed supplied referencedData, the rules define the semantically valid
subset:
[ V_{\Sigma,R,referencedData}
{ data\in D_\Sigma \mid Valid(data,referencedData) } ]
Therefore:
[ \boxed{ V_{\Sigma,R,referencedData}
{ data\in D_\Sigma \mid \forall rule\in R,\ Bool(Eval(rule,data,referencedData))=true } } ]
If the Libertas schema and its validation rules are taken as the normative definition of the data model, then:
Schema
+
Validation Rules
=
Semantic Type
or formally:
[ \boxed{ SemanticType(\Sigma,R,referencedData)
{data\in D_\Sigma\mid Valid(data,referencedData)} } ]
Changing referencedData may change this subset without changing the schema,
rule, or current data. For example, the rule
{$.maximum} == null || [] <= {$.maximum} : "ABOVE_MAXIMUM"; accepts 50
when the supplied maximum is 100, rejects it when the maximum is 40, and
accepts it when there is no reference context because of its explicit null guard.
For a fixed schema, rule set, and referencedData, every schema-compliant
concrete data instance therefore has a deterministic validation result:
[ \boxed{ Valid_{\Sigma,R,referencedData}: D_\Sigma \rightarrow {VALID,INVALID} } ]
20. Examples
20.1 Numeric Range
Schema:
struct Range {
float min;
float max;
float value;
}
Rules:
[min] <= [max] : "INVALID_RANGE";
[value] >= [min] &&
[value] <= [max] : "VALUE_OUT_OF_RANGE";
20.2 Nullable Limit
[limit] == null ||
[limit] >= 0 : "NEGATIVE_LIMIT";
20.3 Union
Schema:
A {
int x;
}
B {
string name;
}
A | B item;
Rules:
([item] is A && [item/x] >= 0) ||
([item] is B && [item/name] != null) : "INVALID_ITEM";
20.4 Union Projection Without Explicit Type Test
[item/value] == null ||
[item/value] >= 0 : "NEGATIVE_VALUE";
This means:
ExampleIf
valueexists in the concrete union value, it must be non-negative.
20.5 Array
count([items]) > 0 : "ITEM_REQUIRED";
all(item in [items],
[item/name] != null &&
[item/value] >= 0
) : "INVALID_ITEM";
20.6 Array of Union Values
all(item in [items],
([item] is A &&
[item/x] >= 0)
||
([item] is B &&
[item/name] != null)
) : "INVALID_ITEM";
20.7 Named type, field, and function ownership
/// @LibertasStringResources(ValidationMessages)
const ValidationMessages = {
NONNEGATIVE: "Enter zero or more.",
PERCENTAGE_MAXIMUM: "Enter a percentage no greater than 100.",
INVALID_RANGE: "The lower limit cannot exceed the upper limit."
} as const;
/** @LibertasValidationRules('[] == null || [] >= 0 : "NONNEGATIVE";') */
export type NonNegative = number;
export interface Range {
/** @LibertasValidationRules('[] == null || [] <= 100 : "PERCENTAGE_MAXIMUM";') */
percentage: NonNegative;
}
/** @LibertasValidationRules('[minimum] == null || [maximum] == null || [minimum] <= [maximum] : "INVALID_RANGE";') */
export declare function configure(minimum: number, maximum: number, range: Range): void;
The percentage value must satisfy both the named-type and field-local rules.
The function rule sees minimum and maximum in one root. The explicit null guards
are valid even though the source types are not nullable, because the editor may not
have received user input yet. If one of those arguments remains null at final
submission, its validation rule may pass while the independent mandatory-field
check still rejects the task data.
20.8 Reference-context lookup
[] == null ||
{$.limits.maximum,$.legacy.maximum} == null ||
[] <= {$.limits.maximum,$.legacy.maximum} : "ABOVE_PRIOR_MAXIMUM";
This field-level rule uses the first non-null maximum found in the current
reference context under the shared source-then-path ordering. During first-time
composition with no reference context, every curly reference
is null. The explicit null guard makes this rule pass in that state without
evaluating the comparison against an absent maximum. Once a reference object
supplies a maximum, the same rule enforces it. A typed | fallback can instead
supply an application-selected limit when the referenced value is absent.
20.9 Calendar-date constants
issued and arrival are DateOnly fields:
[issued] == null || [issued] >= d"1990-01-01" : "ISSUED_BEFORE_1990";
[arrival] == null ||
[arrival] >= d"2026-01-01" && [arrival] <= d"2026-12-31" : "ARRIVAL_OUTSIDE_TAX_YEAR";
The same constant can bound the date chooser itself, as in
@LibertasRangeMin('d"1990-01-01"').
20.10 Bitflags membership
facts is an integer whose Bitflags attribute names the catalog
PersonFacts, and dependentSupport is a Number:
[facts] == null ||
!([facts] has PersonFacts.ReleasedToYou) ||
[dependentSupport] != null : "RELEASED_CLAIM_NEEDS_SUPPORT";
When the person's facts include ReleasedToYou, the support amount is
required, regardless of which other flags are set. Without the operator the
same rule needs the bit position, as in [facts] % 2 == 1.
20.11 Calendar years and month periods
firstYear is an integer year and deathDate a DateOnly field. An inherited
Roth account's first year is not after the year of death:
[firstYear] == null || [deathDate] == null ||
[firstYear] <= year([deathDate]) : "ROTH_FIRST_YEAR_AFTER_DEATH";
periodStart and placedInService are DateOnly fields and periodMonths is
the integer 24 or 60. The measuring period's last day,
add_months([periodStart], [periodMonths]) - 1, falls in the placed-in-service
year:
[periodStart] == null || [periodMonths] == null || [placedInService] == null ||
year(add_months([periodStart], [periodMonths]) - 1) == year([placedInService])
: "MEASURING_PERIOD_OUTSIDE_SERVICE_YEAR";
A 24-month period beginning d"2024-01-01" ends d"2025-12-31"; one beginning
d"2024-03-15" ends d"2026-03-14".
20.12 Totals and composite keys
Each element of contributions has a Number amount, and each element of
carrybacks a credit and an integer year:
[correctedPrincipal] == null ||
[correctedPrincipal] <= sum([contributions/amount]) : "PRINCIPAL_ABOVE_CONTRIBUTIONS";
is_unique_set(row in [carrybacks], [row/credit], [row/year]) : "CARRYBACK_DUPLICATE";
With no contributions the sum is 0, so any positive principal fails. Two
carryback rows may share a credit or a year, but not both.
21. Core Design Principles
The Libertas Data Validation Rules language is based on the following principles:
- Validation operates on concrete data and incomplete client editor state.
- Schema compliance and semantic validity are separate.
- Current data uses
[a/b/c]; external lookup uses explicit{...}paths. - A path resolves to
null, one concrete value, or an ordered collection when array fan-out selects multiple nodes. - Union types require no special path-access mechanism.
istests the selected Union variant or Enumeration member.- Null values use ordinary
== nulland!= null, including on required fields during editing. - A rule that accepts temporary
nullnever overrides final mandatory-field validation. FinalOnlydefaults tofalse; it is a rule-set directive, not a schema attribute, and only explicittruemakes that declaration final-only.nullimplicitly becomesfalsein boolean context.- No other general truthiness conversion exists.
- Single
|returns the first non-null operand unchanged and short-circuits every later operand. - Rules are pure and deterministic for fixed schema interpretation, rule, data, and supplied referencedData.
- Arrays use cursor traversal and collection operators rather than general-purpose loops.
- For fixed referencedData, a valid rule set defines a total VALID/INVALID decision over every schema-compliant concrete data instance.
The schema and pure rules define the model; each evaluation additionally
supplies referencedData:
[ \boxed{ \text{Libertas Semantic Data Type}
\text{Schema} + \text{Pure Validation Rules} } ]