# CEL functions

Capless uses [CEL](https://cel.dev) (Common Expression Language) everywhere logic runs on your data: stream validators, read filters and maps, erasure predicates and materialization mappings. One environment, deterministic and bounded.

## Contexts and variables

| Context | Used by | Variables | Must return |
|---|---|---|---|
| `validator` | `POST /v1/streams` (`validator`) | `entry` | `bool` |
| `filter` | `/read`, `/changes`, sources | `entry`, `id`, `cursor`, `batchId`, `committedAt` | `bool` |
| `map` | `/read`, `/changes`, sources | `entry`, `id`, `cursor`, `batchId`, `committedAt` | any JSON value (sources: an object) |
| `erasure` | `POST …/erasures` (`filter`) | `entry`, `id`, `cursor`, `batchId`, `committedAt` | `bool` |

`entry` is the JSON record (`dyn`). `id`, `cursor` and `batchId` are strings; `committedAt` is a `timestamp` (the batch's commit time). `GET /v1/functions` returns this description as JSON.

## Try it: dry runs

```sh
curl -s https://api.capless.sh/v1/functions/evaluate -H "authorization: Bearer $CAPLESS_API_KEY" \
  -d '{"kind":"map","expression":"{\"user\": entry.user_id, \"cents\": int(entry.amount * 100.0)}","entry":{"user_id":"u1","amount":1.5}}'
# → {"result":{"user":"u1","cents":150}}
```

`kind` is `validator`, `filter`, `map` (default) or `erasure`; `variables` optionally sets `id`, `cursor`, `batchId`, `committedAt`.

## Examples

```text
# Validators
has(entry.user_id) && entry.user_id != ""
entry.amount >= 0 && entry.currency in ["EUR", "USD"]

# Filters
entry.type == "pageview" && entry.path.startsWith("/docs")
entry.type == "click" && has(entry.rage)
has(entry.utm) && entry.utm.source == "newsletter"
entry.sku.matches("^SKU-[0-9]{6}$")
committedAt >= timestamp("2026-10-29T00:00:00Z")
entry.items.exists(i, i.price > 100.0)

# Maps
{"user": entry.user_id, "total": double(entry.amount)}
{"path": entry.path, "lcp": has(entry.vitals) ? entry.vitals.lcpMs : null}
entry.tags.filter(t, t != "internal")
{"id": id, "committedAt": committedAt, "hourUtc": committedAt.getHours()}

# Erasures
entry.userId == "user_42"
entry.email.endsWith("@example.com")
```

## Numbers

* JSON numbers with an integral value are `int`; all others are `double`. `1` is an `int`, `1.5` and `1.0e3` are `double`.
* Comparisons work across both (`entry.amount > 1`), but arithmetic does not mix them: `entry.amount * 100` fails when `amount` is `1.5`. Normalize with `double(x)` / `int(x)`: `double(entry.amount) * 100.0`.
* Integers beyond 2^53 stay exact end to end (storage, CEL, results).

## Missing fields

Selecting a field that does not exist is an evaluation error. Guard with `has()`:

```text
has(entry.geo) && entry.geo.country == "DE"
```

On reads, an evaluation error fails the request (`422 expression_evaluation_failed`) unless you pass `"onError": "exclude"`. In an erasure predicate, an error on an entry simply does not select it.

## Regular expressions

`matches` takes a **string-literal** ECMAScript pattern. Patterns that could backtrack catastrophically are rejected at compile time (`400 invalid_expression`):

* no backreferences (`\1`, `\k<name>`);
* no lookaround (`(?=…)`, `(?!…)`, `(?<=…)`, `(?<!…)`);
* no quantified group that contains a quantifier or an alternation: `(a+)+`, `(a|aa)*`, `(.*a){3}` are rejected; `^(foo|bar)-[a-z]{2,8}$` is fine.

## Limits

| Limit | Value |
|---|---|
| Expression length | 4096 characters |
| AST nodes | 1000 |
| Nesting depth | 32 |
| Nested comprehension macros (`all`, `exists`, `exists_one`, `map`, `filter`) | 2 |
| Literal list elements / map entries | 256 / 256 |
| Records | ≤ 1 MiB, ≤ 32 levels |

Every request also runs under the Workers CPU limit. Programs are compiled once and cached.

## Determinism

There is no clock, randomness, I/O or user-defined function in the environment, so the same expression on the same entry always returns the same result. That is what makes materializations rebuildable and erasures exact.
