Skip to content

Latest commit

 

History

History
442 lines (336 loc) · 16.5 KB

File metadata and controls

442 lines (336 loc) · 16.5 KB
name objectstack-formula
description Author CEL expressions used across ObjectStack — formula fields, field conditional rules (`visibleWhen`, `readonlyWhen`, `requiredWhen`), validation / sharing / visibility predicates, flow conditions, and dynamic seed values. 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), cron schedules (cron dialect), or L2 hook bodies (those belong in objectstack-data).
license Apache-2.0
compatibility Requires @objectstack/spec v4+ and @objectstack/formula
metadata
author version domain tags
objectstack-ai
1.0
expression
cel, formula, predicate, condition, validation, visibility, seed-dynamic

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.

Strategic context. The future authors of metadata are AI agents. CEL was chosen because it has (a) a formal grammar, (b) a public training corpus, (c) AST-first persistence, and (d) sandboxed bounded execution. The previous custom Salesforce-flavor engine was deleted in M9.5.

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

As of 7.6 (ADR-0032) a malformed expression no longer fails silently. It used to evaluate to null/false (a flow "fired" but did nothing). Now objectstack build fails with a located, corrective, schema-aware message (unknown record.<field> → did-you-mean), and at runtime the engine throws (the flow/rule fails loudly). The validate_expression agent tool runs the same shared validator so you can check an expression before saving.


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\...`forcondition`)
Author L2 hook body (TS code) objectstack-data
Cron schedule objectstack-automation (schedule.expression is cron dialect)
SQL fragment driver-native; not unified into the expression registry

Core contract

Every expression in metadata is the same envelope:

type Expression = {
  dialect: 'cel' | 'js' | 'cron' | 'template';
  source?: string;
  ast?: unknown;
  meta?: { rationale?: string; generatedBy?: string };
};

Four registered dialects (M9.9):

Dialect Engine Purpose Helper Example
cel @marcbachmann/cel-js Computed values + boolean predicates cel`...` / F`...` / P`...` cel`record.amount * 1.1`
cron built-in validator Recurring schedules cron`...` cron`0 6 * * MON`
template built-in interpolator {{path}} text interpolation (notif/prompt/title) tmpl`...` tmpl`Hello {{record.first_name}}`
js (sandboxed, future) Edge cases needing arbitrary JS — avoid n/a reserved

Authors emit the right dialect for the surface. Bare strings on cron and template fields are auto-wrapped at validate time, but emitting the full envelope is preferred for clarity. cron and template use the same variable scope as CEL — you do not learn three languages.

AI authors: when emitting structured-output JSON for metadata, always emit the full envelope { dialect, source } — do not emit bare strings. After M9.7 lands, you will emit ast directly. Until then, emit source and let objectstack compile parse it.


CEL syntax cheat-sheet

Concept CEL
Current record field record.first_name
Previous record (update hooks) previous.status
Hook input payload input.amount
Identity context os.user.id, os.org.slug, os.env
Equality == / !=
Logical && / || / !
Ternary cond ? a : b
String literal 'single quotes' (always)
Membership record.region in ['us', 'eu']
Key existence (NOT null-safety) has(record.foo)
Null check record.foo == null or isBlank(record.foo)

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.

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. Source: packages/formula/src/stdlib.ts.

The canonical list is CEL_STDLIB_FUNCTIONS in packages/formula/src/validate.ts — a test asserts every entry resolves at runtime, so this table stays in sync with it.

Dates

Function Returns Notes
now() timestamp Current instant. Pinned per evaluation run; deterministic in build
today() timestamp UTC start-of-day (midnight)
daysFromNow(n) timestamp now() + n days — keeps the current time-of-day (NOT midnight)
daysAgo(n) timestamp now()n days — keeps the current time-of-day
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.last_service, record.cycle_days) = 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

Numbers

Function Returns Notes
abs(x) double Absolute value
round(x) int Round to the nearest integer
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.

Only the functions above are callable. An UNKNOWN function — PRIOR(), a legacy ISBLANK(), a typo'd isBlnk()fails objectstack build with a "no matching overload" type error (#1877), 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.0) / record.cost
  : 0.0`

3. Predicate (field rules / visibility / validation)

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. Treat conditionalRequired as a read/compatibility alias only.

❌ Salesforce-flavor — will compile but evaluate to null:

"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: objectstack build runs twice produce byte-identical dist/objectstack.json only when seed dates use CEL.

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.


Mechanical translation table (legacy → CEL)

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

Legacy CEL
bare_field record.bare_field
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

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 formula (when type: 'formula') cel
Field visibleWhen / readonlyWhen / requiredWhen cel
Field conditionalRequired (deprecated alias of requiredWhen) cel
View / Page visibleWhen (form section/field, page component) cel
Field defaultValue (M9.9b) cel
ConditionalValidation when cel
View / Page visibleOn / visibility (deprecated aliases of visibleWhen, ADR-0089) cel
View.criteria filter expression cel
Action disabled cel (or boolean)
Hook condition cel
SharingRule condition cel
Flow.decision expression / edge condition cel (use vars.<step>.<key>)
Workflow.Task dueDate cel (e.g. cel\daysFromNow(3)``)
Workflow criteria cel
GraphQL.ComputedField expression cel
Dataset.records[*] any value cel (via cel\``)
audit / metrics / tracing condition / successCriteria structured | cel

Cron surfaces (recurring schedules)

All accept bare strings (auto-wrapped to {dialect:'cron', source}) or the cron`...` helper. 5- or 6-field cron + aliases (@daily, @hourly, …).

Surface Field
Job.schedule.expression canonical
connector.schedule, etl.schedule, sync.schedule pipelines
system/cache.schedule warmup
system/disaster-recovery.schedule backup + drill
automation/execution.cronExpression scheduled state
api/export.cronExpression scheduled exports (×2)
ai/orchestration.cron recurring runs
ai/devops-agent.iterationFrequency iteration cadence

Template surfaces ({{ path }} interpolation)

Mustache subset — a field/variable path plus an optional whitelisted formatter: {{ path }} or {{ path | formatter[:arg] }}. No conditionals, no arbitrary logic (move logic into a CEL field). Same variable scope as CEL. Double braces only — single {x} is not a valid hole.

Formatters (7.6) — value→string is defined per formatter (not implicit):

Formatter Example Output
currency[:CODE] {{ record.amount | currency }} / :EUR $1,234.50
number[:decimals] {{ record.n | number:2 }} 1,234.50
percent[:decimals] {{ record.rate | percent }} (0.42→) 42%
date[:short|long|iso] / datetime[:…] {{ record.due | date:long }} locale date
upper / lower / trim {{ record.code | upper }} ABC
truncate:N {{ record.body | truncate:80 }}
default:'…' {{ record.x | default:'N/A' }} fallback
json {{ record.obj | json }} JSON
tmpl`Deal {{ record.name }} — {{ record.amount | currency }} closes {{ record.close_date | date:long }}`
Surface Field
Object.titleFormat record title
system/notification email subject + body, SMS message, push body + message (5 fields)
ai/model-registry systemPrompt, userPromptTemplate
ai/agent-action subject, message
ai/nlq.systemPrompt, ai/mcp.systemPrompt prompt templates
integration/connector/github titleTemplate, bodyTemplate (PR + release)
api/graphql cache key

JS surface (sandboxed body)

Reserved for L2 hook bodies / mapping transforms. Use TypeScript source.


Cron quick reference

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

schedule: cron`0 6 * * MON`        // every Monday at 06:00
schedule: cron`@daily`             // alias — every midnight
schedule: cron`*/15 * * * *`       // every 15 minutes

Bare strings work too on cron-typed fields, but the cron helper makes intent explicit.


Template quick reference

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

titleFormat: tmpl`{{record.first_name}} {{record.last_name}}`
subject:     tmpl`Welcome to {{os.org.name}}, {{os.user.name}}!`

Missing paths render as empty string. Date instances are ISO-formatted.


Determinism contract

Builds are deterministic only if:

  1. All seed dynamic values use cel\...`(nonew Date(), no Date.now()`).
  2. CEL stdlib helpers honor the pinned now from EvalContext.
  3. No expression source contains random / non-pure data.

CI runs objectstack build twice and asserts SHA-1 match.


Open questions (track in ROADMAP M9.7+)

  • Authors will emit ast directly once CelExprSchema is published as JSON Schema for AI constrained decoding (M9.7).
  • A visual node-graph editor backed by CelExprSchema is M9.8 (Studio).

Verify your work

A malformed expression no longer fails silently (ADR-0032, see the note near the top of this skill): both 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 did-you-mean. 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