Language

Expressions

The typed CEL subset used for conditions, defaults, derived fields, filters, effects and call arguments.

Expressions are written inside braces (requires { ... }, where { ... }, policy { ... }) or after default, = and in call arguments. They use a small, typed, side effect free subset of CEL. Every expression is type checked against what its position expects, and every name in it resolves against its position.

Syntax

total >= 0
status in [placed, paid]
actor.is(crm.Clerk) || customer == actor.account
sum(lines.map(l, l.amount))
created_at < now - duration("720h")
lines.exists(l, l.amount > 0) && !has(paid_at)

Literals

LiteralType
"text"String
42Int
0.5Decimal (a number with a fraction)
true, falseBool
nullFits only optional types
[a, b]A list of the first item’s type

Operators

From lowest to highest precedence:

OperatorsMeaning
||Or
&&And
==, !=, <, <=, >, >=, inComparison and membership; at most one per level, so write (a < b) == c with parentheses
+, -Addition, subtraction, concatenation
*, /, %Multiplication, division, remainder
!, -Not, negation (prefix)
x.f, x.m(...), x[i]Field access, method call, list index

Parentheses group as usual.

Types

Every expression has a type: a primitive (String, Text, Int, Decimal, Money, Bool, Date, DateTime, Duration, File, Uuid), an enum, a value type, an entity, a list of one of these, or the current actor. Any of them can be optional, written with ?.

GroupTypes
NumericInt, Decimal, Money (in this order of width)
TextString, Text
TemporalDate, DateTime
Orderednumeric, temporal, Duration, text

Operator rules

ExpressionOperandsResult
a || b, a && bBoolBool
!aBoolBool
-anumeric or Durationthe operand type
a == b, a != bcomparableBool
a < b and the other orderingscomparable and orderedBool
a in listlist items comparable with aBool
a + b, a - b, a * b, a / b, a % bnumericthe wider of the two
a + btextString
t + d, t - dt temporal or Duration, d Durationthe type of t
t1 - t2temporalDuration
d * n, d / nd Duration, n numericDuration
l1 + l2lists with comparable itemsthe list type
l[i]a list and an Intthe item type

Two types are comparable when they are both numeric, both text, the same primitive, the same declaration, or lists of comparable items. null compares with anything.

Arithmetic and negation produce a non optional result. Reading a field through an optional value gives an optional result: order.customer.name is optional when customer is.

Assignability

Where a position expects a type (a field default, a derived field, an effect assignment, a call argument), the value must fit it:

  • null fits only an optional type.
  • A narrower numeric type fits a wider one: an Int fits Decimal and Money, a Decimal fits Money, not the other way round.
  • A list fits a list whose items fit.
  • Otherwise the types must be comparable and of the same kind.

Conditions (invariants, policies, preconditions, guards, filters) must be Bool.

Name resolution

Names in expressions are not references. They resolve by the position of the expression.

Everywhere

NameStands forType
actorThe current actorThe actor; its fields are those declared by any actor in the workspace
nowThe current timeDateTime
tenantThe current tenantThe entity of the single declared tenant; unknown when there is none or more than one

Per position

PositionAlso visibleExpected type
Entity field default or = derivedThe entity’s fields, thisThe field type
Value type, event or actor field default or = derivedThe fields of that declarationThe field type
invariantThe entity’s fields, thisBool
Entity policyThe entity’s fields, thisBool
Action policy, requiresThe fields of the action’s entity, this, the action’s inputsBool
effect in an action: set, create, emit valuesThe fields of the action’s entity, this, the action’s inputsThe assigned field’s type
Transition whenThe fields of the machine’s entity, thisBool
Transition effect valuesThe fields of the machine’s entity, thisThe assigned field’s type
Job whereThe fields of the run action’s entity, this, the run action’s inputsBool
Query where, query policyThe fields of the query’s entity, thisBool
Projection computed fieldThe fields of the source entity (the query’s entity for a query source), thisAny
Use case policyThe use case’s parametersBool
Call arguments in step, on ... do, await ... doThe use case’s parametersThe parameter type at that position

An action without on <Entity> has no this and no entity fields, only its inputs.

this

this is the record the expression is about: the entity in entity, action, transition, job, query and projection positions. Its type is the entity, so it fits a field of that entity type: emit OrderPlaced { order = this }.

Bare enum values

You write an enum value without its enum: draft, not OrderStatus.draft. A bare value takes its type from the context:

  • the other side of a comparison: status == draft,
  • the left side of in for the list items: status in [placed, paid],
  • the field it is assigned to: default draft, set priority = high,
  • the parameter it is passed to in a call.

A name that is neither visible at the position nor a value of the expected enum is an error: Unknown name "missing".

Builtins

BuiltinArgumentType
duration(s)String or Text, such as "24h"Duration
size(x), x.size()A list, String or TextInt
has(x)Any expression, typically an optional fieldBool
sum(list), min(list), max(list)A list of numbers or DurationThe item type
list.map(x, expr)A variable and an expression over itA list of the expression type
list.filter(x, cond)A variable and a Bool conditionThe list type
list.exists(x, cond)A variable and a Bool conditionBool
list.all(x, cond)A variable and a Bool conditionBool
s.contains(t), s.startsWith(t), s.endsWith(t), s.matches(t)s and t textBool
actor.is(X)A role or actor name, plain or dotted (crm.Clerk)Bool

The functions take exactly one argument. In map, filter, exists and all, the first argument is a variable name bound to each item inside the second argument.

Example

module sales {
  role Clerk
  actor human Shopper {
    account: Account
  }

  enum OrderStatus {
    draft
    placed
    paid
  }

  value Line {
    product: String
    amount: Money
  }

  entity Account {
    name: String
  }

  tenant Company on Account

  entity Order {
    reference: String unique
    account: Account
    lines: Line[]
    status: OrderStatus state default draft
    created_at: DateTime
    paid_at: DateTime?
    total: Money = sum(lines.map(l, l.amount))
    big_lines: Int = size(lines.filter(l, l.amount > 100))
    age: Duration = now - created_at

    invariant "has lines" { lines.size() > 0 && lines.all(l, l.amount >= 0) }
    invariant "reference format" { reference.startsWith("SO") && !reference.contains(" ") }

    policy read { actor.is(Clerk) || account == actor.account }
    policy update { account == tenant && status in [draft, placed] }
  }

  query Stale from Order {
    where { status == draft && created_at < now - duration("720h") && !has(paid_at) }
  }

  projection OrderRow from Order {
    reference
    line_count = lines.size()
    net = total * 0.8
  }
}

How tools use the typer

Besides checking, the expression typer records which element every name stands for. The semantic graph turns these into uses edges, and the rename edit operation follows them, so renaming a field also renames it inside every expression that reads it.