Milano Expression Language
Status: Beta v0.1.0 · 2026-08-15
Defines the grammar and semantics of the expression strings carried by the $expr wrapper. Expressions are pure, statically typed, and total: after the gate accepts a document, evaluation can never fail. Both runtimes implement this spec independently; the conformance suite is the arbiter of identical behavior.
Grammar
The complete grammar, EBNF. Whitespace (spaces and tabs) may appear between any two tokens and is insignificant; there are no comments.
expression = coalesce ;
coalesce = or , [ "??" , coalesce ] ; (* right-associative *)
or = and , { "||" , and } ;
and = equality , { "&&" , equality } ;
equality = comparison , { ( "==" | "!=" ) , comparison } ;
comparison = additive , { ( "<" | "<=" | ">" | ">=" ) , additive } ;
additive = multiplicative , { ( "+" | "-" ) , multiplicative } ;
multiplicative = unary , { ( "*" | "/" | "%" ) , unary } ;
unary = ( "!" | "-" ) , unary | postfix ;
postfix = primary , { "." , identifier } ;
primary = literal | call | reference | "(" , expression , ")" ;
call = identifier , "(" , [ expression , { "," , expression } ] , ")" ;
reference = identifier ; (* must be a reserved root *)
literal = number | string | "true" | "false" | "null" ;
number = digit , { digit } , [ "." , digit , { digit } ] ; (* no leading dot, no exponent *)
string = "'" , { character | "\'" | "\\" } , "'" ;
identifier = letter , { letter | digit | "_" } ;
Notes:
- A bare
identifierinprimaryposition must be one of the reserved roots (state,context,event); anything else is aSchemaViolationat the gate. Function names appear only incallposition. - Negative literals are the unary
-operator applied to a number. - A
numberwithout a decimal point is anintliteral; with one, adoubleliteral.
References
- Reserved roots:
state,context, andevent(the last only insideonbindings of events declaring a payload). - Record fields are accessed with
.(dot). Field access requires a non-optional record type; an optional must be resolved with??first. This rule is checked at the gate, which is what makes null dereference impossible at runtime. - There is no array indexing in v0.1.
Literals
int: decimal digits, optionally negated. A literal outside the 64-bit range is aSchemaViolationat the gate: what the producer wrote is rejected, never silently changed.double: decimal digits with a decimal point, optionally negated.string: single-quoted, with\'and\\escapes.bool:true,false.null: valid only where the expected type is optional.
Operators
In precedence order, tightest first. Parentheses group.
| Level | Operators | Operands |
|---|---|---|
| 1 | ! - (unary) | bool; int or double |
| 2 | * / % | numeric |
| 3 | + - | numeric; + also concatenates when both operands are strings |
| 4 | < <= > >= | numeric only |
| 5 | == != | scalars of the same type after numeric promotion; optionals comparable to null. Arrays and records are not comparable in v0.1: comparing them is a SchemaViolation at the gate |
| 6 | && | bool, short-circuit |
| 7 | || | bool, short-circuit |
| 8 | ?? | optional T on the left, T on the right; result T; right-associative |
Binary operators associate left except ??, which associates right.
Numeric semantics
Fixed exactly, because two independent runtimes must agree to the bit:
- Promotion. When
intanddoublemeet in an arithmetic or comparison operator, theintconverts todouble(IEEE 754 round-to-nearest) and the operation is a double operation.intwithintstaysint. - Integer arithmetic. 64-bit two’s complement, wrapping on overflow. Division truncates toward zero; the sign of
%follows the dividend. - Division and modulo by zero (int). The result is
0, and the occurrence is reported to the engine observer. Evaluation does not fail. - Double arithmetic. IEEE 754 binary64 throughout: division by zero yields infinities,
0.0/0.0yields NaN, and NaN compares unequal to everything including itself. - Conversions.
double(x)converts an int exactly when representable, otherwise round-to-nearest.int(x)truncates toward zero and saturates at the int64 bounds; saturation is reported to the observer.
Strings
+concatenates two strings. There is no implicit stringification: mixing a string with a number in+is aSchemaViolationat the gate.str(x)converts scalars to strings, locale-independently: ints in decimal; bools astrue/false. Doubles use a Milano-defined format, never the platform default: non-finite values arenan,inf,-inf; finite values use the shortest round-trip digits, rendered as plain decimal (integral values keep one fractional digit:5.0) while the normalized exponent is within [-4, 15], otherwise as scientific notationd[.ddd]e[-]NNwith a lowercasee, no plus sign, no zero padding.- Ordering operators do not apply to strings; equality does.
- Substring functions (
contains,startsWith,endsWith) compare Unicode scalar sequences literally: no normalization, no grapheme clustering. trimremoves exactly the characters with the Unicode White_Space property, from an explicit table both runtimes share; platform whitespace helpers are not used.
Functions
The complete v0.1 set. All functions are pure and total; all arguments are evaluated.
| Function | Signature | Notes |
|---|---|---|
str | scalar to string | Locale-independent formats above |
int | double to int | Truncates toward zero, saturates, reports saturation |
double | int to double | Round-to-nearest |
concat | strings… to string | Two or more arguments |
length | string or array to int | Strings: Unicode scalar count |
isEmpty | string or array to bool | |
contains | string, string to bool | |
startsWith | string, string to bool | |
endsWith | string, string to bool | |
trim | string to string | Removes leading and trailing Unicode whitespace |
if | bool, T, T to T | Both branches type-check to the same T; both are evaluated (evaluation is pure, so this is observable only through cost) |
There are no regular expressions in v0.1: string validation beyond these functions belongs to the producer or the host. There are no case-mapping functions in v0.1: case rules are locale-sensitive and belong to renderers.
Typing and totality
- Every expression has a static type, determined at the gate from literals, declared state and context types, event payload types, operator rules, and function signatures. A property expression must type-check to the property’s declared type; mismatches are a
SchemaViolationat the gate. - A non-optional
Tis accepted wherever an optionalTis expected; the reverse never holds. - After the gate: no type errors (static), no null dereference (the
??rule), no division failures (defined results), no overflow traps (wrapping and saturation). Evaluation is total. The conformance suite includes vectors for every boundary in this section.