---
title: Expressions (CEL)
description: Canonical CEL-based expression language for formulas, predicates, conditions, and dynamic seed values
---

# Expressions

ObjectStack uses a **single canonical expression language** for every place a
piece of metadata needs to compute a value or evaluate a condition:

- **Formula fields** (`type: 'formula'`)
- **Predicates** — validation `condition`, sharing `condition`, field
  conditional rules (`visibleWhen`, `readonlyWhen`, `requiredWhen`),
  conditional visibility (`visibleOn`), action `disabled`, view filter
  `criteria`, hook `condition`, flow decisions
- **Dynamic seed values** — fixtures whose value depends on the install-time
  clock or identity context

The language is **[CEL](https://github.com/google/cel-spec)** (Google's Common
Expression Language, Apache-2.0), evaluated through `@objectstack/formula`
(thin wrapper over [`@marcbachmann/cel-js`](https://github.com/marcbachmann/cel-js))
plus the ObjectStack standard library.

> **Why CEL?** Formal grammar, abundant public training corpus (so AI authors
> emit it natively), AST-first persistence, and sandboxed execution with
> bounded cost. ObjectStack does **not** ship a Salesforce-flavor DSL — the
> previous custom 22-function engine was deleted in M9. See
> [north-star §8](../concepts/north-star.mdx) "No private expression DSL".

---

## The `Expression` envelope

Every expression in metadata is persisted as the same envelope:

```ts
type Expression = {
  dialect: 'cel' | 'js' | 'cron' | 'template';
  source?: string;          // surface syntax
  ast?: unknown;            // parsed AST (filled by `objectstack compile`)
  meta?: { rationale?: string; generatedBy?: string };
};
```

ObjectStack ships **four** registered dialects (M9.9):

| Dialect    | Use for                                              | Helper        |
|:-----------|:-----------------------------------------------------|:--------------|
| `cel`      | computed values, predicates, defaults, formulas      | `` cel`...` `` / `` F`...` `` / `` P`...` `` |
| `cron`     | recurring schedules (Job, ETL, sync, exports, jobs)  | `` cron`...` `` |
| `template` | text interpolation (`{{path}}`) — notif/prompt/title | `` tmpl`...` `` |
| `js`       | sandboxed L2 hook bodies (TypeScript source)         | n/a           |

All dialects share the same variable scope (`record`, `previous`, `os.user`,
`os.org`, `os.env`, `vars` for flow steps), so you author once and never have
to relearn the syntax across surfaces.

<Callout type="warn">
**Predicates are bare CEL — never wrap field references in `{…}` braces.** The
most common mistake (and the root cause of issue #1491) is a condition like
`{record.rating} >= 4`: in CEL, `{…}` is a **map literal**, so it is a parse
error. Write bare CEL: `record.rating >= 4`. Braces belong only in `{{ … }}`
text templates. As of 7.6 a malformed expression no longer silently does
nothing — it fails `objectstack build` and throws at runtime.
</Callout>

**Template formatting (7.6).** A `template` hole is a field path with an optional
whitelisted formatter — `{{ path | formatter[:arg] }}` — with defined value→string
semantics (no arbitrary logic; put logic in a CEL field):

```ts
tmpl`Deal {{record.name}} — {{record.amount | currency}} closes {{record.close_date | date:long}}`
```

Formatters: `currency[:CODE]`, `number[:decimals]`, `percent[:decimals]`,
`date[:short|long|iso]`, `datetime[:…]`, `upper`, `lower`, `trim`, `truncate:N`,
`default:'…'`, `json`. Single-brace `{x}` is **not** a valid template hole.

At **input** time you may write a bare string for shorthand — the spec
transforms it into the right envelope based on the field type. The compiled
artifact always contains the full envelope (and, after M9.2, the AST).

```ts
import { F, P, cel, cron, tmpl } from '@objectstack/spec';
import { ObjectSchema, Field } from '@objectstack/spec/data';

export const Invoice = ObjectSchema.create({
  name: 'invoice',
  titleFormat: tmpl`Invoice {{record.invoice_no}} – {{record.customer.name}}`,
  fields: {
    total: Field.formula({
      result_type: 'currency',
      formula: F`record.subtotal + record.subtotal * record.tax_rate`,
    }),
    po_number: Field.text({
      requiredWhen: P`record.amount > 10000`,
    }),
    // M9.9b — declarative default, evaluated at insert time
    issued_date: Field.date({
      defaultValue: cel`today()`,
    }),
  },
});
```

The three CEL tagged-template helpers — `cel`, `F` (formula alias), `P`
(predicate alias) — are interchangeable; pick whichever reads best at the
call site. `cron` and `tmpl` produce their respective envelopes.

---

## CEL primer

CEL is documented at [cel-spec](https://github.com/google/cel-spec/blob/master/doc/langdef.md).
Cheat-sheet of what you'll write daily:

| Concept | Syntax |
|:---|:---|
| Field on current record | `record.first_name` |
| Previous (in update hooks) | `previous.status` |
| Input to a hook | `input.amount` |
| Equality / inequality | `==` / `!=` |
| Logical | `&&` / `\|\|` / `!` |
| Ternary | `cond ? then : else` |
| String literal | `'single quotes'` |
| Membership | `record.region in ['us', 'eu']` |
| Field present | `has(record.foo)` (returns true even when value is `null`) |

> **`has()` gotcha:** `has(record.x)` is true whenever the key exists, even
> when its value is `null`. To check for "not blank" use `isBlank(...)` or
> `record.x != null` — see stdlib below.

---

## ObjectStack CEL standard library

Registered automatically into every `Environment` by `@objectstack/formula`.
All functions are pure given a pinned `now`, which is what makes
`objectstack build` artifacts byte-stable across runs.

| Function | Returns | Description |
|:---|:---|:---|
| `now()` | timestamp | Pinned wall-clock at evaluation start |
| `today()` | timestamp | `now()` truncated to UTC start-of-day |
| `daysFromNow(n)` | timestamp | `today() + n` days |
| `daysAgo(n)` | timestamp | `today() - n` days |
| `isBlank(v)` | bool | True for `null`, `undefined`, `''`, `[]` |
| `coalesce(v, fallback)` | dyn | `v` when non-null, else `fallback` |

Add new helpers in
[`packages/formula/src/stdlib.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/formula/src/stdlib.ts).
Keep them pure, dependency-free, and AI-readable.

### Variable scope

`@objectstack/formula` builds the scope per evaluation:

| Binding | Source | Available in |
|:---|:---|:---|
| `record` | the row being evaluated | formulas, validation, sharing, visibility |
| `previous` | row before update | hooks, validation on update |
| `input` | hook payload | hooks |
| `os.user` | install / request user | seed, predicates with identity |
| `os.org` | active organization | seed, predicates |
| `os.env` | install env (`production`, `staging`, …) | seed, predicates |

---

## Cookbook

### Computed full name (handle nullable parts)

```ts
{
  name: 'full_name',
  type: 'formula',
  formula: F`coalesce(record.salutation, '') + ' '
            + coalesce(record.first_name, '') + ' '
            + coalesce(record.last_name, '')`,
  result_type: 'text',
}
```

CEL throws on `null + string`, so wrap every nullable operand in
`coalesce(..., '')`.

### Conditional formula

```ts
{
  name: 'roi',
  type: 'formula',
  formula: F`coalesce(record.actual_cost, 0) > 0
             ? ((coalesce(record.actual_revenue, 0) - record.actual_cost) * 100.0) / record.actual_cost
             : 0.0`,
  result_type: 'percent',
}
```

### Predicate (visibility, read-only, conditional required)

```ts
{
  name: 'rating',
  type: 'picklist',
  visibleOn: P`record.status == 'qualified'`,
}

Field.text({
  visibleWhen: P`record.type == 'business'`,
  readonlyWhen: P`record.status == 'approved'`,
  requiredWhen: P`record.amount > 10000`,
})
```

### Dynamic seed dates

Use CEL **inside seed records** to defer evaluation to install time. The
SeedLoader pins a single `now` per load run, so the same dataset produces
identical SHA-1 across builds while still being "fresh" relative to whoever
installs the package.

```ts
import { defineDataset, cel } from '@objectstack/spec';

export const opportunityDataset = defineDataset({
  object: 'opportunity',
  records: [
    {
      id: 'opp_acme',
      name: 'Acme Q3 Renewal',
      close_date: cel`daysFromNow(45)`,
      created_at: cel`now()`,
    },
  ],
});
```

Without CEL, `new Date()` would be evaluated at **compile** time — your
package would ship with the timestamp from your laptop and every customer
would see "stale" demo data. With CEL, the date resolves on the customer's
clock at install time.

### Validation rule

```ts
{
  name: 'amount_positive',
  type: 'expression',
  condition: P`record.amount > 0`,
  errorMessage: 'Amount must be positive.',
}
```

### Hook condition

```ts
{
  name: 'notify_on_escalation',
  on: 'after_update',
  condition: P`previous.status != 'escalated' && record.status == 'escalated'`,
  handler: 'notifyOpsTeam',
}
```

---

## Determinism contract

Two consecutive `objectstack build` runs with no source changes must produce
**byte-identical** `dist/objectstack.json`. CEL plus pinned `now` is what
makes this possible — there are zero compile-time-evaluated `Date.now()`
calls in seed metadata. CI enforces this via SHA-1 comparison; if you add a
new dialect or stdlib helper, ensure it preserves determinism.

---

## Migration from the legacy formula DSL

The previous custom recursive-descent engine
(`packages/objectql/src/formula.ts`, deleted in M9.5) had a Salesforce-style
function set. Mechanical translation:

| Legacy | CEL equivalent |
|:---|:---|
| `bare_field` | `record.bare_field` |
| `=` / `<>` | `==` / `!=` |
| `AND` / `OR` / `NOT` | `&&` / `\|\|` / `!` |
| `"text"` | `'text'` (single quotes preferred) |
| `IF(c, a, b)` | `c ? a : b` |
| `ISBLANK(x)` | `isBlank(record.x)` |
| `CONCAT(a, b, …)` | `a + b + …` (with `coalesce(...,'')`) |
| `TODAY()` / `NOW()` | `today()` / `now()` |
| `IN (a, b)` | `record.x in [a, b]` |
| `ISCHANGED(x)` (in update hook) | `previous.x != record.x` |
| `MONTH_DIFF(a, b)` | _not yet — add to stdlib_ |

Salesforce compatibility is **not** a project goal. Authors targeting
Salesforce semantics rewrite their formulas in CEL.

---

## Programmatic API

```ts
import { ExpressionEngine } from '@objectstack/formula';

// Compile + cache
const compiled = ExpressionEngine.compile({ dialect: 'cel', source: 'record.x * 2' });

// Evaluate
const result = ExpressionEngine.evaluate(
  { dialect: 'cel', source: 'record.x * 2' },
  { record: { x: 21 } },
);
// => { ok: true, value: 42 }
```

The low-level engine never throws — `evaluate()` returns `{ ok: false, error }`.
But **call sites must not silently swallow that** (ADR-0032): `objectstack build`
**fails** on an invalid expression (with a located, schema-aware message), and at
runtime the flow/rule engines **throw** a loud, attributed error instead of
treating a bad expression as `false`/`null`. The `validate_expression` agent tool
runs this same validator so you can check an expression before saving.

---

## Related

- [Field Metadata](./metadata/field) — declaring `type: 'formula'` fields
- [Seed Data](./seed-data) — using `cel\`...\`` for dynamic install-time values
- [Hook Bodies](./hook-bodies) — when to use a JS hook vs a CEL `condition`
- [`packages/formula/`](https://github.com/objectstack-ai/framework/tree/main/packages/formula) — engine + stdlib source
- [north-star §8](../concepts/north-star.mdx) — anti-pattern: private DSLs
