objectstack-ai/objectstack

objectstack-formula

Author CEL expressions used across ObjectStack — formula fields, field conditional rules (`visibleWhen`, `readonlyWhen`, `requiredWhen`), validation / sharing / visibility predicates, flow conditions, and dynamic seed values. This is the companion skill that objectstack-data, -ui, -api and -automation each tell you to load alongside them. Use when the user is writing an `F`, `P`, or `cel` tagged-template literal, or asks "how do I express X as a formula / predicate". Do not use for SQL fragment…

First seen May 21, 2026

Installation

$ npx skills add objectstack-ai/objectstack --skill objectstack-formula

Also in this package

Other skills from objectstack-ai/objectstack · top by installs.

npx skills add objectstack-ai/objectstack

Browse all from objectstack-ai/objectstack

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 51
License LICENSE
Default branch main
Open issues 582
Status Active

Skill metadata

Parsed from SKILL.md frontmatter.

Version1.2
LicenseApache-2.0
CompatibilityRequires @objectstack/spec 17.x and @objectstack/formula 17.x (CEL)
More metadata
author
objectstack-ai
version
1.2
domain
expression
tags
cel, formula, predicate, condition, validation, visibility, seed-dynamic

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 20,996 B
  • docs SUMMARY.md 599 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 187 installs

SKILL.md

Expressions (CEL) — ObjectStack Formula Protocol

ObjectStack has one expression language across every domain that needs computation or boolean predicates: CEL (Google Common Expression Language). This skill is the canonical reference for AI authors emitting formula / condition / predicate / dynamic-seed metadata.

**Predicates / formulas are bare CEL — never wrap field references in {…}
braces.** The #1 authoring mistake 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 are only for {{ … }}
text templates (see Cron and template surfaces).


Skill Boundaries

Need Use instead
Define a type: 'formula' field objectstack-data (and embed CEL via F\...\``)
Define seed records objectstack-data (use cel\...\`` for dynamic dates)
Author flow / automation step objectstack-automation (use P\...\` for condition`)
Author L2 hook body (TS code) objectstack-data
Cron schedule objectstack-automation
SQL fragment driver-native; not unified into the expression registry

Core contract

Every expression in metadata is the same envelope — dialect plus source, with optional ast and meta — declared as Expression / ExpressionInput in shared/expression.zod.ts. Three registered dialects:

Dialect Engine Purpose Helper Example
cel @marcbachmann/cel-js Computed values + boolean predicates ` cel... / F... / P... ` ` celrecord.amount * 1.1 `
cron built-in validator Recurring schedules ` cron... ` ` cron0 6 MON `
template built-in interpolator {{path}} text interpolation (notif/prompt/title) ` tmpl... ` ` tmplHello {{record.first_name}} `

There is no js dialect — it was retired. Procedural JavaScript is the L2 ScriptBody { language: 'js' } authoring surface (hook bodies, mapping transforms — see objectstack-data), not an expression dialect.

AI authors: when emitting structured-output JSON for metadata, always
emit the full envelope { dialect, source } — never a bare string.


CEL syntax cheat-sheet

Concept CEL
Current record field record.first_name
Previous record (update hooks, validation rules) previous.status — §5
Master-detail line item's header parent.status — cell readonlyWhen / requiredWhen
Metadata-editing form row data — the row under edit, repeater rows included
Hook input payload input.amount
Identity context os.user.id, os.org.id, os.org.tier, os.env
Equality == / !=
Logical && / `\ \ / !`
Ternary cond ? a : b
String literal 'abc' — CEL parses "abc" too
Membership record.region in ['us', 'eu']
Key existence (NOT null-safety) has(record.foo)
Null check record.foo == null or isBlank(record.foo)

The org context is { id, tier } — there is no os.org.slug or os.org.name. The evaluator also binds the current user as currentuser (alias user) per ADR-0068 — spec field docs write predicates like currentuser.positions.

has() is NOT a null check

has(record.x) is true whenever the key exists, even when its value is null. To check for "value present and non-blank" use the stdlib helper isBlank() or compare to null explicitly.

Every predicate reads a record — and, where §5 binds it, a previous — that is total over the object's declared fields: a declared column the driver never returned reads as null, not as a fault. So has(record.<declaredfield>) and has(previous.<declaredfield>) are uniformly true and tell you nothing at all. The idiom that reads like a guard is not one:

# WRONG — both has() calls are true on a NULL row, so this reaches `null < null`,
# CEL has no overload, the predicate aborts and the write is rejected.
has(record.start_date) && has(record.end_date) && record.end_date < record.start_date

# RIGHT
record.start_date != null && record.end_date != null && record.end_date < record.start_date

This is a publish-time rejection, not advice. os build / os validate / os lint and the runtime publish gate reject any validation-rule or hook predicate that applies an ordering (< <= > >=) or arithmetic (+ - * / %) operator to a declared nullable field — no required: true, no defaultValue, no default option — unless an explicit != null / == null / !isBlank() test dominates it in the same boolean branch. has() deliberately does not satisfy that gate. has() over an undeclared key stays legal: that is its real use — telling "absent from this PATCH" apart from "explicitly null".

Null + string throws

CEL has no implicit null coercion. null + 'foo' throws no such overload: dyn<null> + string. Wrap every nullable string operand in coalesce(..., '').


ObjectStack CEL standard library

Registered automatically by @objectstack/formula, which ships dist only — there is no src/ to read in an installed app. Its exported CELSTDLIBFUNCTIONS is the advertised bare-callable subset, pinned by two tests: every entry resolves at runtime, and this table documents them all.

Dates

Function Returns Notes
now() timestamp Current instant. Pinned per evaluation run; deterministic in build
today() timestamp Reference-timezone calendar day, expressed as UTC midnight (not plain UTC start-of-day)
daysFromNow(n) timestamp Calendar-day: today() + n days, at UTC midnight (never carries time-of-day)
daysAgo(n) timestamp Calendar-day: today()n days, at UTC midnight
daysBetween(a, b) int Whole days from a to b (negative if b precedes a). daysBetween(today(), record.due) = days remaining
addDays(d, n) timestamp Shift any date by n days (negative ok). addDays(record.lastservice, record.cycledays) = next due date
addMonths(d, n) timestamp Shift any date by n months; clamps to month-end (addMonths(date('2026-01-31'), 1) → Feb 28)
date(s) / datetime(s) timestamp Parse an ISO date / date-time string to a timestamp

No date arithmetic. A date mixed with a number faults and the build
rejects it; end - start does not fault — it yields a duration stored as
{}.

Want Write — never end - start, date + n, today() + 30
Span in days daysBetween(start, end)
Inclusive span daysBetween(record.startdate, record.enddate) + 1
Shift a date daysFromNow(n) / addDays(d, n) / addMonths(d, n)
Tenure in years daysBetween(record.hire_date, today()) / 365
Sub-day offset now() + duration("3h") — the calendar helpers land on UTC midnight

Numbers

Function Returns Notes
abs(x) double Absolute value
round(x) int Round to the nearest integer
floor(x) / ceil(x) int Round toward −∞ / +∞ (floor(-1.2) = −2, not −1)
min(a, b) / max(a, b) dyn Smaller / larger operand (numeric comparison)

Strings

Function Returns Notes
upper(s) / lower(s) string Case conversion
trim(s) string Strip surrounding whitespace ('' for null)
contains(s, sub) bool Substring test
startsWith(s, p) / endsWith(s, p) bool Prefix / suffix test
matches(s, re) bool Regex test
joinNonEmpty(list, sep) string Join, dropping null/empty entries

Collections / null-ish

Function Returns Notes
isBlank(v) bool true for null, undefined, '', []
isEmpty(v) bool true for null, undefined, empty string / list / map
coalesce(v, fallback) dyn v when non-null, else fallback
len(v) int Length of a string / list / map

Plus CEL built-ins: has(x), size(x), int(x), string(x), bool(x), double(x), timestamp(s), duration(s).

If you need a helper that doesn't exist, prefer adding it to the stdlib (small, pure, dependency-free) over inlining a complex CEL expression.

The list above is CELSTDLIBFUNCTIONS — the bare-callable public subset:
the names you may write as fn(x). Receiver methods (s.split(','),
list.map(...)) are called on a value, never bare, and are not in this table;
four bare primitives (bytes, dyn, type, uint) resolve but are withheld
from the catalogue by an authoring decision — don't reach for them. An UNKNOWN
function — PRIOR(), a legacy ISBLANK(), a typo'd isBlnk() — **fails
os build** with a "no matching overload" type error, rather than silently
no-op'ing the predicate at run time. Use previous.x (not PRIOR()),
isBlank() (not ISBLANK()).


Mandatory patterns for AI emission

1. Computed text formula — always coalesce nullable operands

Correct

F`coalesce(record.salutation, '') + ' '
  + coalesce(record.first_name, '') + ' '
  + coalesce(record.last_name, '')`

Wrong (CEL throws on null + string)

F`record.salutation + ' ' + record.first_name + ' ' + record.last_name`

2. Conditional numeric formula — guard divisor

F`coalesce(record.cost, 0) > 0
  ? ((coalesce(record.revenue, 0) - record.cost) * 100) / record.cost
  : 0.0`

3. Predicate (field rules / visibility / validation)

<!-- os:check -->

import { P } from '@objectstack/spec';

P`record.status == 'qualified'`;
P`record.amount > 10000 && record.region in ['us', 'eu']`;
P`!isBlank(record.po_number)`;

For field-level conditional rules, emit the canonical field properties: visibleWhen, readonlyWhen, and requiredWhen. Never emit conditionalRequired — it was REMOVED in protocol 17 and is a parse error.

❌ Salesforce-flavor — fails CEL compile: os build errors with a located message, and the flow engine throws if it ever reaches runtime:

"status = 'qualified'"
"amount > 10000 AND region IN ('us', 'eu')"
"NOT(ISBLANK(po_number))"

4. Dynamic seed value — use cel\\` not new Date()`

{ close_date: cel`daysFromNow(45)`, created_at: cel`now()` }

❌ Compile-time evaluation — every customer gets the package author's clock:

{ close_date: new Date(Date.now() + 45 * 86400000), created_at: new Date() }

This is the determinism gate: two consecutive os build runs produce byte-identical dist/objectstack.json only when every dynamic seed value is ` cel... — no new Date(), no Date.now(), no random or otherwise impure source. The stdlib helpers honour the pinned now from EvalContext`, so they are safe inside one.

5. Update hook condition — previous vs record

P`previous.status != 'escalated' && record.status == 'escalated'`

ISCHANGED-style logic does not exist as a function; use explicit previous comparison.

record is the record's state, not this write's diff: stored row ⊕ payload, so record.status == 'escalated' is true on every update of an already-escalated record. Comparing against previous is the only way to say "just became". Hook conditions and validation predicates bind the same two roots — one scope, one meaning, whichever surface reads it.

Where previous is bound, and where it is not:

Surface / event previous
Update hook condition (single-record write), validation rule on update the stored pre-write row
Insert events (beforeInsert / afterInsert), validation rule on insert unbound — there is no prior state
**after* hook condition / record-change flow trigger on a predicate (multi: true) write** that row's pre-write row — a bulk write fires after-hooks once PER MATCHED ROW
Validation rule on a predicate bulk update that row's pre-write row — per row
before* hook condition on a predicate (multi: true) write unbound — a before hook fires ONCE for the whole batch (it may still rewrite the shared payload), so there is no single prior record. record is the bare payload here too, so a declared* field this write does not set is unevaluable as well

⚠️ An unevaluable condition ABORTS the operation. Referencing previous where it is unbound — like a typo'd key (record.stauts), a retired field, or a comparison CEL has no overload for — does not degrade to "the hook did not fire": it fails the write, with an error naming the hook and the key — before and after alike, with no onError escape (onError governs a handler that throws, and the condition is evaluated before any handler runs). A condition that does not even compile aborts the same way. So write insert-event conditions over record alone.

A transition condition needs no special handling for bulk writes. Write it once, on an after* event, and it means the same thing whether the write carries an id or a predicate:

// Fires once per row that ACTUALLY transitioned — on `update(id)` and on
// `update({multi: true})` alike.
P`previous.status != 'done' && record.status == 'done'`

A predicate (multi: true) write is N record changes, so every record-scoped declaration on it is evaluated per rowprevious is that row's own pre-write state, record its real state, not the bare payload (ADR-0058, bulk-write addendum). Record-change flow triggers ride the same dispatch. The one exception is the before row of the table above, and it is not a bug to be fixed later: put transition conditions on after, and keep before* conditions to the incoming payload (record.<field this write sets>).

Above ~10 000 matched rows the platform refuses a predicate write on an object with after-hooks rather than fan out that many handler runs inside one write — paginate the write. It is a refusal, never a silent downgrade to one hook call.


Mechanical translation table (legacy → CEL)

When migrating Salesforce-flavor metadata, apply these rules in order:

Legacy CEL
bare_field record.bare_field — except in a flow condition, below
OLD.x previous.x
NEW.x record.x
= (comparison) ==
<> !=
AND &&
OR `\ \ `
NOT(x) !x
"abc" 'abc'
IF(c, a, b) c ? a : b
ISBLANK(x) isBlank(record.x)
CONCAT(a, b) coalesce(a, '') + coalesce(b, '')
TODAY() / NOW() today() / now()
IN (a, b, c) in [a, b, c]
ISCHANGED(x) previous.x != record.x
MONTH_DIFF, MID, LEFT, RIGHT, SUBSTITUTE not in stdlib — propose addition

⚠️ OLD.x and ISCHANGED(x) both land on previous.x, which exists only
where previous is bound — see §5. On an insert event, or in a before*
hook condition on a multi: true predicate write, it is not; that
does not quietly skip the hook, it fails the write. On after* events it
IS bound, per matched row, on bulk and single-record writes alike.

⚠️ Flow conditions are the exception to row 1. The automation engine
spreads the record's variables to top level, so a bare status resolves in a
flow start condition. record.status resolves there too and is the only
spelling valid on every other surface — prefer it.


Surfaces that take an Expression

All of these spec fields accept string | Expression. The build normalizes to the envelope.

CEL surfaces (predicates + computed values)

Surface Field Dialect
Field expression (when type: 'formula') cel
Field visibleWhen / readonlyWhen / requiredWhen cel
View / Page visibleWhen (form section/field, page component) cel
Field defaultValue (envelope only; bare string = literal) cel
ConditionalValidation when cel
Action disabled cel (or boolean)
Hook condition cel
SharingRule condition cel
Flow.decision expression / edge condition cel (use vars.<step>.<key>)
Seed.records[*] any value cel (via cel\\``)
audit / metrics / tracing condition / successCriteria structured \ cel

⚠️ A formula field is virtual — no driver materialises a column for it, so all three query axes refuse one at both doors: where and searchableFields answer 400 INVALIDFIELD, orderBy answers 400 INVALIDSORT. It still READS correctly, which is why refusing matters: a where on one used to answer 200 with zero rows. Denormalise onto a stored field and query that. summary and autonumber have real columns and need no such care.

⚠️ A form-view visibleWhen is the one predicate here that faults OPEN. It is CLIENT-SIDE only, and an unbound root falls back to true, so the control renders for everyone — never use one as access control; that is permission-set field-level security. Everywhere else on this table the opposite holds: an unevaluable Hook / SharingRule condition or validation predicate aborts the write (§5).

View list filters are not a CEL surface — they are structured JSON filter rules (ViewFilterRuleSchema), so do not emit CEL there. For "last 30 days" style windows use the date macro tokens (data/date-macros.zod.ts) — objectstack-query rules/filters.md has the token list.

Cron and template surfaces

Two more dialects ride the same envelope. Neither is CEL; both take a bare string (auto-wrapped) or their helper, and read the same variable scope.

Dialect Helper Grammar Carriers
cron ` cron0 6 MON ` 5- or 6-field cron, or one of @yearly @annually @monthly @weekly @daily @hourly @reboot Job.schedule.expression (canonical), connector.schedule, automation/execution.cronExpression, api/export.cronExpression
template ` tmplHello {{ record.first_name }} ` {{ path }} or `{{ path \ formatter[:arg] }} — double braces only, no conditionals; the formatter whitelist is TEMPLATE_FORMATTERS, exported from @objectstack/formula` system/email-template subject / bodyHtml / bodyText, ai/model-registry promptTemplate.system / .user, Object.titleFormat (deprecated → nameField, ADR-0079)

shared/expression.zod.ts declares both surfaces and their carriers. Missing template paths render as the empty string. Move logic into a CEL field: a template holds a path and a formatter, nothing else.


Verify your work

A malformed expression does not fail silently (ADR-0032). It used to evaluate to null / false — a flow "fired" and did nothing; now os validate and os build run the shared validator over every formula and predicate in the stack — CEL syntax plus record.<field> existence on the target object — and fail non-zero with a located, did-you-mean message, while at runtime the engine throws and the rule fails loudly. Use os validate as the fast post-edit check (no artifact emitted; npm run validate in a scaffolded project). To check a single expression before saving it, call the validate_expression agent tool, which runs the same validator inline.


See also

  • [references/index.md](./references/index.md) — the Zod schemas behind every

surface above

  • node_modules/@objectstack/spec/src/shared/expression.zod.tsExpression,

ExpressionInput, cel / F / P