Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/skill-catalog-9.8-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
---

docs(skill): sync objectstack-formula/automation CEL function tables to the 9.8.0 stdlib

The authoring skills (what the AI reads to know what's callable) under-listed the
stdlib: they showed ~6 functions and capped with "only the stdlib above", omitting
the 14 functions registered in 9.8.0 (`daysBetween`, `abs`, `round`, `min`, `max`,
`upper`, `lower`, `contains`, `startsWith`, `endsWith`, `matches`, `len`, `isEmpty`,
`date`/`datetime`). So AI authors never reached for `daysBetween` (days-remaining)
and kept hand-rolling workarounds.

Rewrites the formula stdlib table (grouped, with the CEL built-ins), fixes the
`daysFromNow`/`daysAgo` note (they keep the wall-clock time, NOT midnight), and
rewrites the automation "time-relative rule" anti-pattern to the precise one-day
**window** pattern (`$gte daysFromNow(N)` / `$lt daysFromNow(N+1)` + `$or`) instead
of the imprecise `BETWEEN TODAY and TODAY+N`. Adds a drift-guard test pinning the
skill ↔ `CEL_STDLIB_FUNCTIONS` so the AI-facing list can't fall behind the runtime
catalog again.
32 changes: 32 additions & 0 deletions packages/formula/src/skill-catalog-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';

import { CEL_STDLIB_FUNCTIONS } from './validate';

/**
* Drift-guard (#1928 follow-up): the objectstack-formula authoring skill is what
* the AI author reads to know which CEL functions exist. It MUST stay in sync
* with the runtime catalog — a function advertised in the catalog but missing
* from the skill means the AI never reaches for it; one in the skill but not the
* catalog means the AI calls a function that faults the build. This pins the
* skill ↔ `CEL_STDLIB_FUNCTIONS` mapping so neither drifts silently again
* (mirrors the runtime drift-guard in cel-engine.test.ts).
*/
describe('objectstack-formula skill ↔ CEL_STDLIB_FUNCTIONS', () => {
const here = dirname(fileURLToPath(import.meta.url));
const skillPath = resolve(here, '../../../skills/objectstack-formula/SKILL.md');
const skill = readFileSync(skillPath, 'utf8');

it('documents every advertised stdlib function', () => {
// A function is "documented" if its name appears as `` `name(` `` (a call
// form) anywhere in the skill — robust to table layout / grouping changes.
const missing = CEL_STDLIB_FUNCTIONS.filter((fn) => !skill.includes(`\`${fn}(`));
expect(
missing,
`These CEL_STDLIB_FUNCTIONS are not documented in skills/objectstack-formula/SKILL.md:\n` +
`${missing.join(', ')}\nAdd them to the stdlib table so AI authors know they exist.`,
).toEqual([]);
});
});
26 changes: 20 additions & 6 deletions skills/objectstack-automation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ pipelines.
> envelope evaluated by `@objectstack/formula`. Use the `P\`...\`` and
> `cel\`...\`` tagged templates from `@objectstack/spec`. See the
> **objectstack-formula** skill for the full CEL contract, stdlib
> (`now()`, `today()`, `daysFromNow(n)`, `isBlank(v)`, `coalesce(v, fb)`),
> (`now()`, `today()`, `daysFromNow(n)`, `daysBetween(a, b)`, `isBlank(v)`, `coalesce(v, fb)`),
> and the legacy → CEL translation table.

---
Expand Down Expand Up @@ -537,9 +537,22 @@ them right the first time:
8. **Time-relative rules ("alert N days before a date") are SCHEDULE flows, not
record-change date-equality.** `record.end_date == daysFromNow(60)` on a
`record-*` trigger only fires if the record happens to be written on that exact
day — unattended rules never run.
✅ A daily `schedule` flow whose `get_record` filters `end_date` BETWEEN
`{TODAY()}` and `{TODAY() + N}`, then loops over the results.
day — unattended rules never run. **And date EQUALITY never matches anyway**: a
`date` field carries a time component, so `field == daysFromNow(N)` (or
`{ $in: [daysFromNow(N), …] }`) compares two differently-timed timestamps and
silently returns nothing (build warns `flow-date-equality-filter`).
✅ A daily `schedule` flow whose `get_record` filters each tier as a one-day
**window** (`$gte`/`$lt`), never an equality:
```ts
filter: { status: 'active', $or: [
{ end_date: { $gte: cel`daysFromNow(7)`, $lt: cel`daysFromNow(8)` } },
{ end_date: { $gte: cel`daysFromNow(30)`, $lt: cel`daysFromNow(31)` } },
{ end_date: { $gte: cel`daysFromNow(60)`, $lt: cel`daysFromNow(61)` } },
] }
```
Abutting windows tile the timeline so each record matches exactly one tier —
fires once, idempotent, no guard field. For "days remaining" in the message,
`daysBetween(today(), record.end_date)`.

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

10. **Conditions are bare CEL — only the stdlib is callable.** `now()`,
`today()`, `daysFromNow(n)`, `daysAgo(n)`, `isBlank(v)`, `coalesce(a, b)`,
`trim(s)`, plus CEL built-ins (`has`, `size`, `contains`, `startsWith`, …).
`today()`, `daysFromNow(n)`, `daysAgo(n)`, `daysBetween(a, b)`, `isBlank(v)`,
`coalesce(a, b)`, `abs/round/min/max`, `upper/lower/contains/matches`, plus CEL
built-ins (`has`, `size`, `int`, `string`, …) — see **objectstack-formula** for the full table.
An UNKNOWN function (`PRIOR()`, a typo'd name) **fails the build**. And never
wrap a field reference in `{…}` inside a condition — that's a template brace
and fails as CEL: write `record.x`, not `{record.x}`.
Expand Down
55 changes: 45 additions & 10 deletions skills/objectstack-formula/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,24 +126,59 @@ in `coalesce(..., '')`.
Registered automatically. Source:
[`packages/formula/src/stdlib.ts`](../../packages/formula/src/stdlib.ts).

The canonical list is `CEL_STDLIB_FUNCTIONS` in
[`packages/formula/src/validate.ts`](../../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 |
| `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 |
|:---|:---|:---|
| `now()` | timestamp | Pinned per evaluation run; deterministic in build |
| `today()` | timestamp | UTC start-of-day |
| `daysFromNow(n)` | timestamp | `today() + n*24h` |
| `daysAgo(n)` | timestamp | `today() - n*24h` |
| `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 stdlib above + CEL built-ins (`has`, `size`, `contains`,
> `startsWith`, `endsWith`, `matches`, `min`, `max`, …) are callable.** An
> UNKNOWN function — `PRIOR()`, a legacy `ISBLANK()`, a typo'd `isBlnk()` — now
> **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()`).
> **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()`).

---

Expand Down
Loading