Skip to content

Commit 5881cae

Browse files
os-zhuangclaude
andauthored
docs(skill): sync CEL function tables to 9.8.0 stdlib + drift-guard (#1967)
The authoring skills under-listed the stdlib (6 functions + "only the above"), omitting the 14 registered in 9.8.0 (daysBetween/abs/round/min/max/upper/lower/ contains/startsWith/endsWith/matches/len/isEmpty/date/datetime) — so AI authors never used daysBetween and kept hand-rolling workarounds. - Rewrite objectstack-formula stdlib table (grouped + CEL built-ins); fix the daysFromNow/daysAgo note (keep wall-clock time, not midnight). - Rewrite objectstack-automation "time-relative rule" anti-pattern to the precise one-day window pattern ($gte daysFromNow(N) / $lt daysFromNow(N+1) + $or); add daysBetween for days-remaining. - Add a drift-guard test (packages/formula) pinning skill ↔ CEL_STDLIB_FUNCTIONS. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 90108e0 commit 5881cae

4 files changed

Lines changed: 116 additions & 16 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
---
3+
4+
docs(skill): sync objectstack-formula/automation CEL function tables to the 9.8.0 stdlib
5+
6+
The authoring skills (what the AI reads to know what's callable) under-listed the
7+
stdlib: they showed ~6 functions and capped with "only the stdlib above", omitting
8+
the 14 functions registered in 9.8.0 (`daysBetween`, `abs`, `round`, `min`, `max`,
9+
`upper`, `lower`, `contains`, `startsWith`, `endsWith`, `matches`, `len`, `isEmpty`,
10+
`date`/`datetime`). So AI authors never reached for `daysBetween` (days-remaining)
11+
and kept hand-rolling workarounds.
12+
13+
Rewrites the formula stdlib table (grouped, with the CEL built-ins), fixes the
14+
`daysFromNow`/`daysAgo` note (they keep the wall-clock time, NOT midnight), and
15+
rewrites the automation "time-relative rule" anti-pattern to the precise one-day
16+
**window** pattern (`$gte daysFromNow(N)` / `$lt daysFromNow(N+1)` + `$or`) instead
17+
of the imprecise `BETWEEN TODAY and TODAY+N`. Adds a drift-guard test pinning the
18+
skill ↔ `CEL_STDLIB_FUNCTIONS` so the AI-facing list can't fall behind the runtime
19+
catalog again.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { readFileSync } from 'node:fs';
3+
import { fileURLToPath } from 'node:url';
4+
import { dirname, resolve } from 'node:path';
5+
6+
import { CEL_STDLIB_FUNCTIONS } from './validate';
7+
8+
/**
9+
* Drift-guard (#1928 follow-up): the objectstack-formula authoring skill is what
10+
* the AI author reads to know which CEL functions exist. It MUST stay in sync
11+
* with the runtime catalog — a function advertised in the catalog but missing
12+
* from the skill means the AI never reaches for it; one in the skill but not the
13+
* catalog means the AI calls a function that faults the build. This pins the
14+
* skill ↔ `CEL_STDLIB_FUNCTIONS` mapping so neither drifts silently again
15+
* (mirrors the runtime drift-guard in cel-engine.test.ts).
16+
*/
17+
describe('objectstack-formula skill ↔ CEL_STDLIB_FUNCTIONS', () => {
18+
const here = dirname(fileURLToPath(import.meta.url));
19+
const skillPath = resolve(here, '../../../skills/objectstack-formula/SKILL.md');
20+
const skill = readFileSync(skillPath, 'utf8');
21+
22+
it('documents every advertised stdlib function', () => {
23+
// A function is "documented" if its name appears as `` `name(` `` (a call
24+
// form) anywhere in the skill — robust to table layout / grouping changes.
25+
const missing = CEL_STDLIB_FUNCTIONS.filter((fn) => !skill.includes(`\`${fn}(`));
26+
expect(
27+
missing,
28+
`These CEL_STDLIB_FUNCTIONS are not documented in skills/objectstack-formula/SKILL.md:\n` +
29+
`${missing.join(', ')}\nAdd them to the stdlib table so AI authors know they exist.`,
30+
).toEqual([]);
31+
});
32+
});

skills/objectstack-automation/SKILL.md

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ pipelines.
3939
> envelope evaluated by `@objectstack/formula`. Use the `P\`...\`` and
4040
> `cel\`...\`` tagged templates from `@objectstack/spec`. See the
4141
> **objectstack-formula** skill for the full CEL contract, stdlib
42-
> (`now()`, `today()`, `daysFromNow(n)`, `isBlank(v)`, `coalesce(v, fb)`),
42+
> (`now()`, `today()`, `daysFromNow(n)`, `daysBetween(a, b)`, `isBlank(v)`, `coalesce(v, fb)`),
4343
> and the legacy → CEL translation table.
4444
4545
---
@@ -537,9 +537,22 @@ them right the first time:
537537
8. **Time-relative rules ("alert N days before a date") are SCHEDULE flows, not
538538
record-change date-equality.** `record.end_date == daysFromNow(60)` on a
539539
`record-*` trigger only fires if the record happens to be written on that exact
540-
day — unattended rules never run.
541-
✅ A daily `schedule` flow whose `get_record` filters `end_date` BETWEEN
542-
`{TODAY()}` and `{TODAY() + N}`, then loops over the results.
540+
day — unattended rules never run. **And date EQUALITY never matches anyway**: a
541+
`date` field carries a time component, so `field == daysFromNow(N)` (or
542+
`{ $in: [daysFromNow(N), …] }`) compares two differently-timed timestamps and
543+
silently returns nothing (build warns `flow-date-equality-filter`).
544+
✅ A daily `schedule` flow whose `get_record` filters each tier as a one-day
545+
**window** (`$gte`/`$lt`), never an equality:
546+
```ts
547+
filter: { status: 'active', $or: [
548+
{ end_date: { $gte: cel`daysFromNow(7)`, $lt: cel`daysFromNow(8)` } },
549+
{ end_date: { $gte: cel`daysFromNow(30)`, $lt: cel`daysFromNow(31)` } },
550+
{ end_date: { $gte: cel`daysFromNow(60)`, $lt: cel`daysFromNow(61)` } },
551+
] }
552+
```
553+
Abutting windows tile the timeline so each record matches exactly one tier —
554+
fires once, idempotent, no guard field. For "days remaining" in the message,
555+
`daysBetween(today(), record.end_date)`.
543556

544557
9. **`script` nodes must name a callable.** Set `config.actionType` to a built-in
545558
side-effect (`email` / `slack`) **or** `config.function` to a function
@@ -573,8 +586,9 @@ them right the first time:
573586
that's an L2 **hook** (objectstack-data) — hooks get `ctx.api`; flow functions don't.
574587

575588
10. **Conditions are bare CEL — only the stdlib is callable.** `now()`,
576-
`today()`, `daysFromNow(n)`, `daysAgo(n)`, `isBlank(v)`, `coalesce(a, b)`,
577-
`trim(s)`, plus CEL built-ins (`has`, `size`, `contains`, `startsWith`, …).
589+
`today()`, `daysFromNow(n)`, `daysAgo(n)`, `daysBetween(a, b)`, `isBlank(v)`,
590+
`coalesce(a, b)`, `abs/round/min/max`, `upper/lower/contains/matches`, plus CEL
591+
built-ins (`has`, `size`, `int`, `string`, …) — see **objectstack-formula** for the full table.
578592
An UNKNOWN function (`PRIOR()`, a typo'd name) **fails the build**. And never
579593
wrap a field reference in `{…}` inside a condition — that's a template brace
580594
and fails as CEL: write `record.x`, not `{record.x}`.

skills/objectstack-formula/SKILL.md

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -126,24 +126,59 @@ in `coalesce(..., '')`.
126126
Registered automatically. Source:
127127
[`packages/formula/src/stdlib.ts`](../../packages/formula/src/stdlib.ts).
128128

129+
The canonical list is `CEL_STDLIB_FUNCTIONS` in
130+
[`packages/formula/src/validate.ts`](../../packages/formula/src/validate.ts) — a
131+
test asserts every entry resolves at runtime, so this table stays in sync with it.
132+
133+
**Dates**
134+
135+
| Function | Returns | Notes |
136+
|:---|:---|:---|
137+
| `now()` | timestamp | Current instant. Pinned per evaluation run; deterministic in build |
138+
| `today()` | timestamp | UTC **start-of-day** (midnight) |
139+
| `daysFromNow(n)` | timestamp | `now()` + `n` days — **keeps the current time-of-day** (NOT midnight) |
140+
| `daysAgo(n)` | timestamp | `now()``n` days — keeps the current time-of-day |
141+
| `daysBetween(a, b)` | int | Whole days from `a` to `b` (negative if `b` precedes `a`). `daysBetween(today(), record.due)` = days remaining |
142+
| `date(s)` / `datetime(s)` | timestamp | Parse an ISO date / date-time string to a timestamp |
143+
144+
**Numbers**
145+
146+
| Function | Returns | Notes |
147+
|:---|:---|:---|
148+
| `abs(x)` | double | Absolute value |
149+
| `round(x)` | int | Round to the nearest integer |
150+
| `min(a, b)` / `max(a, b)` | dyn | Smaller / larger operand (numeric comparison) |
151+
152+
**Strings**
153+
154+
| Function | Returns | Notes |
155+
|:---|:---|:---|
156+
| `upper(s)` / `lower(s)` | string | Case conversion |
157+
| `trim(s)` | string | Strip surrounding whitespace (`''` for null) |
158+
| `contains(s, sub)` | bool | Substring test |
159+
| `startsWith(s, p)` / `endsWith(s, p)` | bool | Prefix / suffix test |
160+
| `matches(s, re)` | bool | Regex test |
161+
| `joinNonEmpty(list, sep)` | string | Join, dropping null/empty entries |
162+
163+
**Collections / null-ish**
164+
129165
| Function | Returns | Notes |
130166
|:---|:---|:---|
131-
| `now()` | timestamp | Pinned per evaluation run; deterministic in build |
132-
| `today()` | timestamp | UTC start-of-day |
133-
| `daysFromNow(n)` | timestamp | `today() + n*24h` |
134-
| `daysAgo(n)` | timestamp | `today() - n*24h` |
135167
| `isBlank(v)` | bool | true for `null`, `undefined`, `''`, `[]` |
168+
| `isEmpty(v)` | bool | true for `null`, `undefined`, empty string / list / map |
136169
| `coalesce(v, fallback)` | dyn | `v` when non-null, else `fallback` |
170+
| `len(v)` | int | Length of a string / list / map |
171+
172+
Plus CEL built-ins: `has(x)`, `size(x)`, `int(x)`, `string(x)`, `bool(x)`,
173+
`double(x)`, `timestamp(s)`, `duration(s)`.
137174

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

141-
> **Only the stdlib above + CEL built-ins (`has`, `size`, `contains`,
142-
> `startsWith`, `endsWith`, `matches`, `min`, `max`, …) are callable.** An
143-
> UNKNOWN function — `PRIOR()`, a legacy `ISBLANK()`, a typo'd `isBlnk()` — now
144-
> **fails `objectstack build`** with a "no matching overload" type error (#1877),
145-
> rather than silently no-op'ing the predicate at run time. Use `previous.x`
146-
> (not `PRIOR()`), `isBlank()` (not `ISBLANK()`).
178+
> **Only the functions above are callable.** An UNKNOWN function — `PRIOR()`, a
179+
> legacy `ISBLANK()`, a typo'd `isBlnk()`**fails `objectstack build`** with a
180+
> "no matching overload" type error (#1877), rather than silently no-op'ing the
181+
> predicate at run time. Use `previous.x` (not `PRIOR()`), `isBlank()` (not `ISBLANK()`).
147182
148183
---
149184

0 commit comments

Comments
 (0)