mcpField
O
objectstack-formula

objectstack-formula

github.com/objectstack-aiDevelopment76225 installsUpdated today
Repository

About objectstack-formula

objectstack-formula is a reference skill for authors of ObjectStack metadata. It standardizes CEL for computed values and predicates, documents the cron and template dialects, and details expression envelopes, null handling, and available library functions.

Key features

  1. 01Uses CEL for formulas and predicates
  2. 02Documents cel, cron, and template dialects
  3. 03Defines the expression metadata envelope
  4. 04Lists date, number, and string helpers
  5. 05Explains null handling and predicate rules

Use cases

  1. 01Write computed fields and conditional predicates
  2. 02Create dynamic seed values in metadata
  3. 03Check CEL null handling and expression errors

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/objectstack-ai/objectstack --skill objectstack-formula

After installing, type/objectstack-formulain Claude Code to confirm it loaded.

Bundled filesreferences/_index.mdevals/cel-predicates-formulas.json

Details

From the project README

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

NeedUse instead
Define a type: 'formula' fieldobjectstack-data (and embed CEL via F\...``)
Define seed recordsobjectstack-data (use cel\...`` for dynamic dates)
Author flow / automation stepobjectstack-automation (use P\...`forcondition`)
Author L2 hook body (TS code)objectstack-data
Cron scheduleobjectstack-automation
SQL fragmentdriver-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:

DialectEnginePurposeHelperExample
cel@marcbachmann/cel-jsComputed values + boolean predicatescel`...` / F`...` / P`...`cel`record.amount * 1.1`
cronbuilt-in validatorRecurring schedulescron`...`cron`0 6 * * MON`
templatebuilt-in interpolator{{path}} text interpolation (notif/prompt/title)tmpl`...`tmpl`Hello {{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

ConceptCEL
Current record fieldrecord.first_name
Previous record (update hooks, validation rules)previous.status — §5
Master-detail line item's headerparent.status — cell readonlyWhen / requiredWhen
Metadata-editing form rowdata — the row under edit, repeater rows included
Hook input payloadinput.amount
Identity contextos.user.id, os.org.id, os.org.tier, os.env
Equality== / !=
Logical&& / || / !
Ternarycond ? a : b
String literal'abc' — CEL parses "abc" too
Membershiprecord.region in ['us', 'eu']
Key existence (NOT null-safety)has(record.foo)
Null checkrecord.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 current_user (alias user) per ADR-0068 — spec field docs write predicates like current_user.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.<declared_field>) and has(previous.<declared_field>) 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 CEL_STDLIB_FUNCTIONS is the advertised bare-callable subset, pinned by two tests: every entry resolves at runtime, and this table documents them all.

Dates

FunctionReturnsNotes
now()timestampCurrent instant. Pinned per evaluation run; deterministic in build
today()timestampReference-timezone calendar day, expressed as UTC midnight (not plain UTC start-of-day)
daysFromNow(n)timestampCalendar-day: today() + n days, at UTC midnight (never carries time-of-day)
daysAgo(n)timestampCalendar-day: today() − n days, at UTC midnight
daysBetween(a, b)intWhole days from a to b (negative if b precedes a). daysBetween(today(), record.due) = days remaining
addDays(d, n)timestampShift any date by n days (negative ok). addDays(record.last_service, record.cycle_days) = next due date
addMonths(d, n)timestampShift any date by n months; clamps to month-end (addMonths(date('2026-01-31'), 1) → Feb 28)
date(s) / datetime(s)timestampParse an ISO date / date-time string to a timestamp
isoDate(t) / isoDatetime(t)stringTimestamp to UTC ISO text, 2026-10-08 / 2026-10-08T17:55:06.123Z. Text in? isoDate(date(s))

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 {}.

WantWrite — never end - start, date + n, today() + 30
Span in daysdaysBetween(start, end)
Inclusive spandaysBetween(record.start_date, record.end_date) + 1
Shift a datedaysFromNow(n) / addDays(d, n) / addMonths(d, n)
Tenure in yearsdaysBetween(record.hire_date, today()) / 365
Sub-day offsetnow() + duration("3h") — the calendar helpers land on UTC midnight

Numbers

FunctionReturnsNotes
abs(x)doubleAbsolute value
round(x)intRound to the nearest integer
floor(x) / ceil(x)intRound toward −∞ / +∞ (floor(-1.2) = −2, not −1)
min(a, b) / max(a, b)dynSmaller / larger operand (numeric comparison)

Strings

FunctionReturnsNotes
upper(s) / lower(s)stringCase conversion
trim(s)stringStrip surrounding whitespace ('' for null)
contains(s, sub)boolSubstring test
startsWith(s, p) / endsWith(s, p)boolPrefix / suffix test
matches(s, re)boolRegex test
joinNonEmpty(list, sep)stringJoin, dropping null/empty entries

Collections / null-ish

FunctionReturnsNotes
isBlank(v)booltrue for null, undefined, '', []
isEmpty(v)booltrue for null, undefined, empty string / list / map
coalesce(v, fallback)dynv when non-null, else fallback
len(v)intLength 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 CEL_STDLIB_FUNCTIONS — 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)

✅

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\`notnew 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 / eventprevious
Update hook condition (single-record write), validation rule on updatethe stored pre-write row
Insert events (beforeInsert / afterInsert), validation rule on insertnull — previous == null || … judges an insert, previous != null && … skips one; a column read off it (previous.x) has no value and faults
A write to an existing row whose prior state is not in handunbound — fails closed, so previous == null never reads an update as an insert
after* hook condition / record-change flow trigger on a predicate (multi: true) writethat row's pre-write row — a bulk write fires after-hooks once PER MATCHED ROW
Validation rule on a predicate bulk updatethat row's pre-write row — per row
before* hook condition on a predicate (multi: true) writethat row's pre-write row — beforeUpdate / beforeDelete dispatch once PER MATCHED ROW too, each on a single-record-shaped context whose input.id names the row (ADR-0058 Addendum II, D1/D2); record is that row's state, stored ⊕ payload, as on any update. Only the payload stays batch-scoped — see below the table

⚠️ An unevaluable condition ABORTS the operation. A column read off previous on an insert (previous.status, where previous is null) — 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 on an insert event read previous only against null, and put a column read off it behind previous != null &&.

A transition condition needs no special handling for bulk writes. Write it once, on a before* or 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 row — previous is that row's own pre-write state, record its real state, not the bare payload (ADR-0058, bulk-write Addenda I and II). Record-change flow triggers ride the same dispatch. before* is no exception to the condition; the asymmetry is what a before* handler WRITES: one updateMany takes one SET clause, so every row's dispatch carries the one payload and a rewrite made on any row applies to every matched row (D3). A rewrite decided per row must assign the same key set on every row, in place — assigned onto the payload object, never a replaced one — because the engine records the keys each row's chain wrote and refuses the batch whole, before any write, when two rows disagree (MULTI_UPDATE_HOOK_KEY_DIVERGENCE, status 400; ADR-0058 Amendment II.3).

Above 10 000 matched rows the platform refuses a predicate write on an object with per-row hooks in either phase — one ceiling for before* and after*, checked before the first dispatch (ADR-0058 Addendum II, D6) — 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:

LegacyCEL
bare_fieldrecord.bare_field — except in a flow condition, below
OLD.xprevious.x
NEW.xrecord.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, SUBSTITUTEnot in stdlib — propose addition

⚠️ OLD.x and ISCHANGED(x) both land on previous.x, which has a value only where previous is a row — see §5. On an insert event previous is null, so the read does not quietly skip the hook, it fails the write unless guarded by previous != null &&. On update and delete events it IS bound, per matched row, in before* and after* alike, on bulk and single-record writes.

⚠️ 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)
SurfaceFieldDialect
Fieldexpression (when type: 'formula')cel
FieldvisibleWhen / readonlyWhen / requiredWhencel
View / PagevisibleWhen (form section/field, page component)cel
FielddefaultValue (envelope only; bare string = literal)cel
ConditionalValidationwhencel
Actiondisabledcel (or boolean)
Hookconditioncel
SharingRuleconditioncel
Flow.decisionexpression / edge conditioncel (use vars.<step>.<key>)
Seed.records[*]any valuecel (via cel\``)
audit / metrics / tracingcondition / successCriteriastructured | 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 INVALID_FIELD, orderBy answers 400 INVALID_SORT. 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.

DialectHelperGrammarCarriers
croncron`0 6 * * MON`5- or 6-field cron, or one of @yearly @annually @monthly @weekly @daily @hourly @rebootJob.schedule.expression (canonical)
templatetmpl`Hello {{ record.first_name }}`{{ path }} or {{ path | formatter[:arg] }} — double braces only, no conditionals; the formatter whitelist is TEMPLATE_FORMATTERS, exported from @objectstack/formulasystem/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 — the Zod schemas behind every surface above
  • node_modules/@objectstack/spec/src/shared/expression.zod.ts — Expression, ExpressionInput, cel / F / P

Comments

Markdown supported