skillZs
★ LIVE SKILL TAGS ★
>>> LIVE SKILLS INDEX <<<
* OPEN SOURCE *
NO LOGIN, NO TRACKING
※ REAL INSTALL DATA ※
← back to all skills
objectstack-ai/objectstack211 installs

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 fragments (driver-native) or L2 hook bodies (those belong in objectstack-data).

How do I install this agent skill?

npx skills add https://github.com/objectstack-ai/objectstack --skill objectstack-formula
view source ↗

Is this agent skill safe to install?

  • Gen Agent Trust Hubpass

    This skill provides instructions and technical references for authoring Google Common Expression Language (CEL) formulas and predicates within the ObjectStack platform. It defines syntax rules, provides a standard library reference, and outlines mandatory coding patterns to ensure formulas are valid, deterministic, and safe to execute. No security issues were detected.

  • Socketpass

    No alerts

  • Snykpass

    Risk: LOW · No issues

What does this agent skill do?

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

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)

✅

<!-- 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\`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 insertunbound — there is no prior state
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. 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 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 exists only where previous is bound — see §5. On an insert event it is not; that does not quietly skip the hook, it fails the write. 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

Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.

<a href="https://skillzs.dev/skills/objectstack-ai/objectstack/objectstack-formula">View objectstack-formula on skillZs</a>