import { Expression, ExpressionInput } from '@objectstack/spec'; import { ASTNode, Environment } from '@marcbachmann/cel-js'; import { FilterCondition } from '@objectstack/spec/data'; /** * @objectstack/formula — public types * * The expression engine surface is intentionally minimal: * * - {@link EvalContext}: input passed by call sites (hooks, seed loader, views). * - {@link EvalResult}: discriminated union — never throws to the caller. * - {@link DialectEngine}: contract any dialect (cel, cron, template) implements. * * The shape is shared across `cel`, `cron` and `template` so the kernel can * route any persisted `Expression` to the correct engine without conditional * logic. */ /** * Runtime context for evaluating an expression. * * Every field is optional — call sites populate only what they have. The CEL * engine binds `record`, `previous`, `input`, `os` directly as top-level * variables when present. */ interface EvalContext { /** Logical "now" snapshot — pinned per evaluation run for determinism. */ now?: Date; /** * Reference timezone (IANA name, e.g. `America/New_York`) for calendar-day * functions `today()` / `daysFromNow()` / `daysAgo()` and for rendering * `datetime` template holes in that zone's wall-clock (ADR-0053 Phase 2). * Defaults to `UTC` when unset. Calendar-day `date` rendering stays tz-naive. */ timezone?: string; /** * Current authenticated subject (hook / action / view contexts). * * ADR-0068: the canonical user contract is {@link EvalUser} from * `@objectstack/spec`, surfaced to predicates as `current_user` (aliases * `user`, `ctx.user`). `positions: string[]` is the only canonical membership field; * (the legacy singular's "overwritten to 'admin' on * promotion" behavior is the footgun ADR-0068 eliminates). */ user?: { id: string; /** CANONICAL (ADR-0068, renamed ADR-0090 D3). Scope-resolved position names. */ positions?: string[]; /** Active organization ID (null = platform / unscoped). */ organizationId?: string | null; email?: string; [key: string]: unknown; }; /** Current organization (multi-tenant context). */ org?: { id: string; tier?: string; [key: string]: unknown; }; /** Deployment environment marker. */ env?: 'prod' | 'dev' | 'test' | string; /** Record-shaped data: target row, hook record, view row, etc. */ record?: Record; /** Previous record state for update hooks. */ previous?: Record; /** Action / flow input payload. */ input?: Record; /** * Optional kernel API for `os.exists / os.count / os.lookup`. * Implemented opportunistically by call sites that have a query engine. */ api?: { exists?: (object: string, predicate: Expression) => boolean; count?: (object: string, predicate: Expression) => number; lookup?: (object: string, id: string) => Record | null; }; /** Free-form bag for niche call sites; merged onto the variable scope. */ extra?: Record; } /** Result of a single evaluation. Never throws; callers branch on `ok`. */ type EvalResult = { ok: true; value: T; } | { ok: false; error: EvalError; }; /** Structured error so AI callers can self-correct. */ interface EvalError { /** * - `parse` source string failed to parse to AST * - `type` static type-check failed * - `runtime` evaluation threw (division by zero, missing field, …) * - `bounds` exceeded execution limits (AST size, depth, …) * - `dialect` no engine registered for `expression.dialect` */ kind: 'parse' | 'type' | 'runtime' | 'bounds' | 'dialect'; message: string; /** Source position when known. */ pos?: { start: number; end: number; }; } /** Contract every dialect engine implements. */ interface DialectEngine { /** Dialect identifier — must match `Expression.dialect`. */ readonly dialect: string; /** * Parse + type-check + emit AST. Source-only — `expression.ast` is what * actually gets persisted in `objectstack.json`. */ compile(source: string): EvalResult; /** Evaluate a fully-resolved expression in the given context. */ evaluate(expr: Expression, ctx: EvalContext): EvalResult; } /** * Dialect-pluggable Expression engine registry. * * Replaces the per-call-site `compileFormula` / `evaluateFormula` direct * imports of the deleted custom engine. Call sites now ask the registry to * dispatch by `expression.dialect`. * * Three real engines are registered at module load: `cel`, `cron`, `template`. * An unregistered dialect yields an explicit `dialect`-kind error from * `evaluate` / `compile` (never a silent `undefined` — the old engine's * anti-pattern). There is deliberately no `js` expression engine: procedural JS * is the L2 `ScriptBody { language: 'js' }` surface, not an expression dialect * (retired in #3278; see ADR-0058 addendum). */ /** Register or replace a dialect engine. */ declare function register(engine: DialectEngine): void; /** Look up a dialect engine without dispatching. */ declare function getEngine(dialect: string): DialectEngine | undefined; /** Whether a real engine is registered for this dialect. */ declare function hasDialect(dialect: string): boolean; /** * The unified evaluation entry point. Replaces the old direct calls to * `evaluateFormula` from the deleted custom engine. */ declare const ExpressionEngine: { register: typeof register; getEngine: typeof getEngine; hasDialect: typeof hasDialect; /** * Compile-only — parse + type-check, returning the engine-native AST. Used * by `objectstack compile` to normalize source into AST in artifacts. */ compile(expr: Expression): EvalResult; /** * Evaluate an expression in the given context. Never throws — branch on * `result.ok`. Errors carry a `kind` for caller-side classification. */ evaluate(expr: Expression, ctx: EvalContext): EvalResult; }; /** * CEL dialect engine — wraps `@marcbachmann/cel-js` with the ObjectStack * stdlib, bounded execution limits, and result coercion. * * Why a thin wrapper: * * - cel-js returns `BigInt` for ints. The kernel and CRM expect plain * numbers, so we coerce at the boundary. * - cel-js parses dotted names as receiver-typed methods; we register * `now()`, `today()`, `daysFromNow()` as bare functions and let `os.*` * refer to context data only (see {@link buildScope}). * - Bounds (`maxAstNodes`, `maxDepth`, …) are enforced spec-wide so * third-party plugins can't ship runaway predicates. */ /** * Default execution bounds. Picked conservatively — every metadata-authored * expression we've seen is well under these. If you hit them, the expression * is too complex for a CEL formula and should move to a hook/action body * (`ScriptBody { language: 'js' }`, the L2 sandboxed surface). */ declare const DEFAULT_LIMITS: { readonly maxAstNodes: 256; readonly maxDepth: 32; readonly maxListElements: 64; readonly maxMapEntries: 64; readonly maxCallArguments: 16; }; /** * Namespace roots that a `record`-scoped CEL site may legitimately reference. * Declared as `map` (dyn values) so member access (`record.foo`) and any * arithmetic/comparison on it defers to runtime — the strict env faults ONLY on * an *undeclared* top-level identifier, i.e. a bare field reference. Generous on * purpose: an unknown root is a missed catch, a missing root is a false positive * that would break the build, so we err toward declaring more. * * ## Why this list is PUBLISHED (#6713) * * Exported for the same reason as {@link collectCelRootIdentifiers} and * {@link firstUndeclaredReference}: a surface that binds a CLOSED set of roots * has to name the roots it does NOT bind, and that complement is * `SCOPE_ROOTS` minus its own allowlist. `@objectstack/lint`'s field-level * `*When` gate is exactly such a surface — it binds `record` / `previous` / * `parent` and nothing else — and it used to carry a hand-written DENYLIST of * three roots instead. A denylist structurally cannot track this list: every * root added here (`current_user` arrived in #6290) is silently unreported at * that surface until somebody remembers to copy it over, and #6713 measured 21 * roots sitting in that gap. * * Consuming the list is NOT the same as consuming {@link firstUndeclaredReference} * and the difference is load-bearing. The strict env also declares CEL's own * TYPE names (`int`, `string`, `bool`, `type`, `map`, …), so * `type(record.x) == string` reports `string` as a root that "resolves" — * legitimate CEL a declaredness oracle cannot tell apart from an unbound * namespace. Membership of THIS list can. */ declare const SCOPE_ROOTS: readonly ["record", "previous", "input", "output", "os", "vars", "variables", "automation", "context", "args", "item", "env", "user", "step", "result", "trigger", "event", "payload", "data", "params", "config", "settings", "ctx", "features", "parent", "current", "current_user"]; /** * In a `record`-scoped CEL site — a `Field.formula` or an object validation * predicate — the evaluation scope binds only the `record`/`previous`/… *namespaces* * (no field flattening). A bare top-level identifier like `amount` or `status` * therefore resolves to nothing and the expression silently evaluates to `null` * / never fires (#1928, the class behind #1927's broken formulas). Returns the * first such bare reference, or `null`. * * Acts ONLY on cel-js's `Unknown variable: X` fault, so it cannot false-positive * on arithmetic/comparison overloads — and it must NOT be applied to flow / * automation conditions, where the record's fields ARE flattened to top-level * and bare references are correct. * * ## The false-NEGATIVE side of that narrowing (#16412) * * The paragraph above states which error this helper cannot make. It does not * state that it makes neither, and it does not: cel-js's checker hands back * exactly ONE error, so when the FIRST one is of another class every undeclared * reference behind it in the same source goes unjudged and the answer is * `null` -- the same value that means "every reference is rooted". A `null` * here is "nothing was reported", never "the source is clean", and a caller * that needs the stronger reading does not get it from this helper. * * The masking is POSITIONAL, not name-keyed: the masked name is not the one * that triggered the first error, so excluding the trigger's own name does not * reach it. Measured on this env: * * data == 'x' && status == 'q' -> null first error `no such * overload: map == * string`; `status` unjudged * status == 'q' && data == 'x' -> "status" first error `Unknown * variable: status` * * ⚠️ {@link celEngine.compile} is not a gate against this, so a caller that * only reaches here on a clean compile is not protected by that gate. `compile` * type-checks in the PERMISSIVE env ({@link CEL_ENV_OPTIONS}, * `unlistedVariablesAreDyn: true`), and the two error classes that reach the * first slot from ordinary authored input fault only HERE: * * - a {@link SCOPE_ROOTS} member -- or an object field sharing one of those * names (`data`, `config`, `result`, `item`, `event`, `input`, `user`, …) -- * as the operand of an operator with no `map` overload, because this env * declares those roots `map` while the permissive one leaves them `dyn`; * - a CEL TYPE name (`type`, `string`, `int`, …) in the same position, already * pinned as a blind spot by `@objectstack/lint`'s `visibility-bare-identifier` * suite -- pinned there per NAME, while the masking it causes is source-wide. * * ⛔ Do not close this by widening the regex onto the overload message: that * false positive is precisely what the narrowing buys off (`type(record.x) == * string` is legitimate CEL). Reporting past the first error needs a re-check * loop over a neutralised source, or a checker entry that returns more than one * error -- cel-js 8.0.0 has none, its `TypeCheckResult` carries a single * `error` -- and either one changes what every consuming rule reports. That is * a design decision, not a patch. */ declare function firstUndeclaredReference(source: string, knownFields?: readonly string[]): string | null; /** * The distinct top-level identifiers (namespace roots) a CEL expression * references — `current.x + vars.step.y` → `['current', 'vars']`, a bare * `amount > 100` → `['amount']`. Member names and function names are not * identifiers and are never reported. * * Built for evaluation sites that expose a CLOSED set of roots (#3447 P2: * approval-node `expression` approvers allow only `current`/`trigger`/`vars`). * Such a site must reject any other root BEFORE evaluating: the runtime env is * `unlistedVariablesAreDyn: true`, so an out-of-contract root (`record.x`, a * bare field) would otherwise evaluate to `null` and silently produce an empty * result instead of an error. Both the lint rule and the runtime pre-check * consume this one helper so the two can never drift. * * Returns `{ ok: false }` with the classifier's message when the source does * not parse — callers surface that as a config error, not an empty root set. */ declare function collectCelRootIdentifiers(source: string): { ok: true; roots: string[]; } | { ok: false; error: string; }; /** * A parsed CEL AST node, re-exported so a consumer can name the type this * package already hands it without importing `@marcbachmann/cel-js` itself. * * The alias is not cosmetic. {@link lowerCelAst} has always *taken* a cel-js * `ASTNode` while the type stayed unexported, so every caller that wanted to * hold an AST had to reach past this package to the parser — which is precisely * how a second, differently-configured parse entry gets built (#4812). Prefixed * `Cel` to match the package's other CEL-domain public names * (`CelFilterCompileResult`, `collectCelRootIdentifiers`, `isPushdownableCel`); * bare `ASTNode` would be ambiguous in a package that also owns the cron and * template dialects. */ type CelAstNode = ASTNode; /** * Parse a CEL source to its AST through the **canonical** front end — the one * answer in this repo to "what parses" (#4812). * * Every other entry point in this package (`compile`, `evaluate`, * {@link collectCelRootIdentifiers}) reaches the parser through the same three * things, and so does this one: * * 1. {@link rewriteNullableTernary} — the #3306 `cond ? value : null` rewrite, * so the AST a consumer analyses is the AST the runtime will execute, not * the shape the author happened to type; * 2. {@link DEFAULT_LIMITS} — the platform's bounds. A source over * `maxAstNodes` / `maxDepth` / `maxListElements` does **not** parse here, * because it does not parse anywhere else on the platform either; * 3. the registered stdlib and `unlistedVariablesAreDyn: true` env. * * A consumer that built its own `new Environment(...)` instead got a different * answer to (2) in particular — it would happily parse, and then reason about, * a predicate `compile()` rejects outright. That is not a hypothetical: it is * what `@objectstack/lint`'s null-guard pass did until #4812. * * Returns `null` — never throws — when the source is empty or does not parse, * so a caller whose job is *not* to adjudicate syntax can skip it in one line * and leave the verdict to the gate that owns it (`validateExpression`, which * reports both the syntax fault and the bounds fault with a message written for * self-correction). * * This is `parse` only, deliberately **not** `parse + check`: `compile()` is the * entry that also type-checks. A caller that wants the AST of an expression * which parses but does not type-check (a great many predicates over `dyn` * operands) must not be denied it, and a caller that wants the type verdict * should ask `compile()` for it. The parity suite pins both halves of that * asymmetry so neither side drifts. */ declare function parseCelToAst(source: string): CelAstNode | null; /** A key of {@link DEFAULT_LIMITS} — the platform bounds a source can overrun. */ type CelLimitKey = keyof typeof DEFAULT_LIMITS; /** How far past a {@link DEFAULT_LIMITS} bound a source actually reaches. */ interface CelBoundsOverrun { /** * WHICH bound was exceeded — `maxAstNodes` / `maxDepth` / `maxListElements` / … * `null` only if cel-js reports a limit fault this package cannot name (see * {@link limitKeyOf}); a guessed key would send the author to shorten the * wrong axis, so the honest answer is "we know it was a bound, not which". */ limit: CelLimitKey | null; /** The platform's value for that bound, i.e. what the source had to stay under. */ limitValue: number | null; /** * What the source itself measures on that axis: the smallest value of * `limits[limit]` under which it parses, every OTHER bound lifted, so the * number is cel-js's own accounting rather than a second implementation of * it. `null` when the measurement was capped (see {@link CEL_BOUNDS_MEASURE_CAP_FACTOR}) * or is not being taken — a bounds *refusal* never measures, because * measuring means re-parsing a source we have just decided is too big. */ measured: number | null; /** * cel-js's own one-line summary — `Exceeded maxAstNodes (256)`. Taken from * `ParseError#summary`, NOT `#message`: the latter is * `formatErrorWithHighlight`'s rendering, which interpolates the author's own * source line (the #6223 hazard). */ summary: string; } /** * The verdict {@link parseCelToAstWithReason} returns — the same three-way * answer {@link classifyCelFault} already grades a thrown fault into, made * available to a caller that has to ACT differently on `bounds` than on * `parse`, rather than collapsing both to `null`. */ type CelParseResult = { ok: true; ast: CelAstNode; } /** Empty / whitespace-only source. Not a fault — "no expression". */ | { ok: false; kind: 'empty'; message: string; } /** A syntax fault. `message` is cel-js's rendered message, verbatim. */ | { ok: false; kind: 'parse'; message: string; } | { ok: false; kind: 'bounds'; /** cel-js's rendered message, verbatim — same string `parse` carries. */ message: string; /** WHICH bound, and by how much. */ overrun: CelBoundsOverrun; /** * The AST an otherwise-identical but **unbounded** parse yields, when the * caller asked for it (`{ admitOverLimit: true }`) — the 17.0.0-rc.x * grace window's input, and nothing else's. `null` otherwise. */ unboundedAst: CelAstNode | null; }; interface ParseCelToAstOptions { /** * Also perform the unbounded parse and hand back its AST + the measured * overrun. **Only** the 17.0.0-rc.x pushdown grace window sets this (see * `cel-pushdown-limits.ts`); it is what lets that window keep compiling a * predicate the platform's bounds refuse, while still naming the bound. It * disappears with the grace window at v17 GA. * * Off by default, deliberately: an unbounded parse of a source we have just * measured as over-budget is work proportional to the source, so a caller * that only wants the verdict must not pay for it. */ admitOverLimit?: boolean; } /** * {@link parseCelToAst}, but it says WHY it refused (#6132). * * `parseCelToAst` collapses "this is not valid CEL" and "this is valid CEL that * is over the platform's budget" into the same `null`, which is right for a * caller whose job is not to adjudicate syntax. It is wrong for a caller whose * job is to *report* the refusal: the RLS / sharing pushdown path fails closed * on a refusal, and "your policy was rejected: parse error" for a predicate * that is perfectly well-formed but 431 AST nodes long sends the author * hunting for a typo that does not exist. This entrance names the bound * (`maxAstNodes` / `maxDepth` / `maxListElements` / …), the platform's value * for it, and what their source actually measures. * * The verdict is graded by the SAME {@link classifyCelFault} the engine's * `compile` / `evaluate` use — error class plus structured `code`, never prose * (#6223). A `bounds` verdict here and a `bounds` verdict from * `celEngine.compile()` are therefore the same judgement of the same fault, * which is the property `cel-parse-reason.test.ts` pins. */ declare function parseCelToAstWithReason(source: string, opts?: ParseCelToAstOptions): CelParseResult; declare const celEngine: DialectEngine; /** * Cron dialect engine. * * Validates cron expressions at compile time without depending on a parser. * Actual schedule firing lives in the scheduler service — this engine just * round-trips the expression through `Expression.evaluate`, returning the * source so callers can hand it to a scheduler library. * * Accepted forms: * - 5-field standard cron: `m h dom mon dow` * - 6-field extended cron: `s m h dom mon dow` * - Aliases: @yearly, @annually, @monthly, @weekly, @daily, @hourly, @reboot */ declare const cronEngine: DialectEngine; /** * Template dialect engine — strict Mustache subset with a formatter whitelist. * * Holes are `{{ path }}` or `{{ path | formatter[:'arg'] }}` (ADR-0032 §3). * Holes are restricted to a **field/variable path** plus a **whitelisted * formatter** — never arbitrary CEL logic — so the grammar stays small (low * author/agent error surface), GUI-pickable (path + formatter dropdown), and * display strings stay declarative. Real logic belongs in `Predicate`/`Expr` * (CEL) fields, where it is validated and visible. * * The variable scope is the same as CEL (`record`, `previous`, `input`, * `os.user/org/env`, plus `extra`), so authors move fluidly between a CEL * formula and a template body without re-learning a namespace. * * Value→string semantics are explicit and defined per formatter (numbers, * dates, money, percent, null), instead of implicit coercion. */ /** Public list of whitelisted template formatters (for introspection/docs). */ declare const TEMPLATE_FORMATTERS: string[]; /** * Apply a whitelisted formatter to a value, the single source of truth for * value→string semantics across dialects. Returns `undefined` for an unknown * formatter name so callers can decide how to handle it (the template engine * rejects at compile time; other consumers may pass the raw value through). * * Exported so renderers that don't run the full CEL template engine — notably * the email pipeline (ADR-0053 Phase 2 slice 4) — format dates, money, etc. * identically to in-app templates, including reference-timezone `datetime`. */ declare function formatValue(name: string, value: unknown, arg: string | undefined, opts?: { locale?: string; timeZone?: string; }): string | undefined; declare const templateEngine: DialectEngine; /** * ObjectStack standard CEL function library. * * Registered into the per-evaluation `Environment` by the CEL engine. All * functions are pure given a pinned `now` — that determinism is what makes * `objectstack build` artifacts byte-stable across runs. * * Function naming intentionally avoids the `os.` prefix because cel-js binds * dotted names to receiver types. Instead, the `os` namespace in CEL holds * *data* (`os.user`, `os.org`, `os.env`) supplied by the caller's * {@link EvalContext}. */ /** * Register the ObjectStack standard library into a CEL environment. * * The `now` resolver is closed over so each call uses the pinned * `EvalContext.now` (or wall-clock fallback). Implementations are kept tiny * and dependency-free — they're the contract surface for AI authors and must * stay legible. */ declare function registerStdLib(env: Environment, now: () => Date, timezone?: string): Environment; /** * Build the variable scope for a single evaluation. Absent fields are simply * not bound — CEL macros (`has(record.foo)`) handle missing-key safely. */ declare function buildScope(ctx: EvalContext): Record; /** * Seed-value resolver. * * `Seed.records` accepts {@link SeedValue} = primitive | Expression | array * | object — install-time resolution walks the tree and replaces any * Expression node with its evaluated result. This is what makes * `close_date: cel\`now() + duration("P30D")\`` resolve to *the customer's* * "today + 30 days" instead of the developer's compile-time clock. */ type SeedPrimitive = string | number | boolean | null | Date; type SeedValue = SeedPrimitive | Expression | SeedValue[] | { [key: string]: SeedValue; }; /** * Recursively resolve a SeedValue. Records that contain Expression leaves are * evaluated with `ctx`; other values are passed through unchanged. * * Returns the first failure encountered. Callers (seed loader) typically * abort the whole record on failure rather than silently writing partial data. */ declare function resolveSeed(value: SeedValue, ctx: EvalContext): EvalResult; /** * Resolve a single record (object of fields), pinning `ctx.now` so all * expressions within see one logical clock. */ declare function resolveSeedRecord(record: Record, ctx: EvalContext): EvalResult>; /** * Normalize an {@link ExpressionInput} (string shorthand OR full envelope) into * a fully-resolved {@link Expression} carrying both `source` and `ast`. * * Returns an EvalResult so the caller can render a structured compile error * pointing at the offending metadata path. */ declare function normalizeExpression(input: ExpressionInput): EvalResult; /** * Walk an arbitrary JSON tree and normalize every embedded Expression in * place. Used by the build pipeline to traverse the assembled metadata * artifact. Returns the first error encountered (paired with the dotted path * for diagnostics) or `null` when fully clean. */ declare function normalizeExpressionTree(root: unknown, path?: string[]): { path: string; error: EvalError; } | null; /** * Canonical CEL → FilterCondition pushdown compiler (ADR-0058 D1/D2/D6). * * ObjectStack has ONE authoring language (CEL) and ONE good interpreter * (`cel-engine.ts`), but historically THREE disconnected "compile-to-filter" * front-ends: `plugin-security/rls-compiler.ts`'s 4-form regex, `plugin-sharing`'s * `celToFilter`, and the ObjectUI array-AST path. They diverged — which is the * root of #1887 (a sharing `condition` that the interpreter understands but no * compiler lowers, so it never enforces). * * This module is the single, canonical lowering. It takes the **same parsed * `@marcbachmann/cel-js` AST the interpreter uses** (`env.parse(src).ast`) and * lowers the pushdown-able subset to a Mongo-style {@link FilterCondition} — the * one shape BOTH backends already consume: the ObjectQL engine `where` (AND-injected * by plugin-security) and the analytics SQL backend * (`service-analytics/read-scope-sql.ts`). One AST, two backends (D6). * * ## Supported subset (ADR-0058 D2) * `==` `!=` `>` `<` `>=` `<=` · `in` (→ `$in`) · `&&` `||` `!` · * `== null` / `!= null` (→ `$null`) · string methods `startsWith` / `endsWith` * / `contains` (→ `$startsWith` / `$endsWith` / `$contains`). * `not in` is `!(x in y)`. Negation wraps in `$not`. * * ## Hard boundaries (ADR-0055 stands) * - **No subqueries, no cross-object traversal.** A field path is a SINGLE * column (`record.region` → `region`, bare `owner` → `owner`). A multi-segment * relation path (`record.account.region`) is an authoring-time compile error, * not a silent join. * - Arithmetic (`+ - * / %`), function calls (`size(...)`), ternary, maps, and * any other non-pushdown shape are a compile error — NEVER silently dropped. * A dropped predicate leaves an object unprotected; failing closed is the * security-correct outcome (ADR-0049/0056 D4). * * ## Value resolution * A leaf rooted at a `variableRoot` (default `current_user`) is resolved against * `opts.variables` to a literal — `current_user.id` → the caller's id, * `current_user.org_user_ids` → a pre-resolved membership array for `$in` * (honours ADR-0055: the runtime pre-resolves the set; the compiler never emits * a subquery). A variable that resolves to `undefined`/`null` yields * `unresolved-variable` (the "no active org" fail-closed path) — and so does a * null/undefined MEMBER of a resolved membership array, which is the same * unresolved value one level in. See {@link lowerMembership} for why the member * is refused rather than dropped. */ type CelFilterFailReason = /** CEL did not parse (syntax error). */ 'parse-error' /** Shape is not pushdown-able (arithmetic, function call, relation traversal, …). */ | 'unsupported' /** A required `variableRoot` reference was undefined/null in `variables`. */ | 'unresolved-variable'; type CelFilterCompileResult = { ok: true; filter: FilterCondition; } | { ok: false; reason: CelFilterFailReason; detail: string; }; interface CelFilterCompileOptions { /** Member-access roots that denote a record FIELD path. Default `['record']`. */ fieldRoots?: readonly string[]; /** Roots resolved as VALUES against {@link variables}. Default `['current_user']`. */ variableRoots?: readonly string[]; /** * Value-resolution context, keyed by variable root. e.g. * `{ current_user: { id, organization_id, org_user_ids } }`. A `record.*` * (field) reference is NEVER resolved here — only `variableRoot` leaves are. */ variables?: Record; } /** Test hook for the WARN memo — a suite must not inherit another's dedupe state. */ declare function __resetPushdownLimitWarnings(): void; /** * Compile a CEL predicate into a {@link FilterCondition}, resolving `variableRoot` * leaves against `opts.variables`. Returns a discriminated result — never throws * for an authoring-level fault; a `false` result with a reason is the caller's * cue to fail closed (deny) or surface a compile error. */ declare function compileCelToFilter(input: string | { source?: string; }, opts?: CelFilterCompileOptions): CelFilterCompileResult; /** * Shape-only check: is this CEL predicate pushdown-able at all? Used by the * authoring gate (ADR-0056 D4) to REJECT a predicate the runtime could only * silently drop. Does not resolve `variables`. */ declare function isPushdownableCel(input: string | { source?: string; }, opts?: Pick): { ok: true; } | { ok: false; reason: CelFilterFailReason; detail: string; }; /** * Lower a pre-parsed cel-js AST node — the variant that lets the interpreter and * the compiler share ONE parse (ADR-0058 D6, "one AST, two backends"). */ declare function lowerCelAst(ast: ASTNode, opts?: CelFilterCompileOptions, mode?: 'value' | 'shape'): CelFilterCompileResult; /** * THE dated switch for the CEL pushdown path's reaction to a * {@link DEFAULT_LIMITS} overrun (#6132). * * ## Why a switch exists at all * * Until #6132 `cel-to-filter.ts` parsed through a **private, limitless** * `new Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true })` * of its own, so the RLS / sharing pushdown path answered a different question * from every other CEL entry on the platform: a 300-term addition, a 60-level * parenthesis nest and a 200-element list all parsed there while * `celEngine.compile()` refused each one (`Exceeded maxAstNodes (256)` / * `maxDepth (32)` / `maxListElements (64)`). Measured on the escalation: an * 80-term conjunction, a 40-level nest and a 200-element `$in` all reached REAL * pushdown SQL, silently. * * Converging the parse (which #6132 does — the pushdown path now goes through * {@link parseCelToAstWithReason}, i.e. #4812's canonical front end) therefore * changes behaviour on a **security-sensitive** path: an over-limit predicate * goes from "compiled and pushed down" to "refused, and the RLS path fails * closed to `RLS_DENY_FILTER`". A deployment whose stored policy happens to sit * over a bound would go from enforcing-something to denying-everything on the * upgrade that shipped the fix. * * ## The ruling (2026-08-08, maintainer's A′, quoted verbatim on #6132) * * > **rc grace, GA flip:** during 17.0.0-rc.x an over-limit predicate on the * > pushdown path still compiles + emits a WARN naming the exceeded limit; at * > v17 GA the runtime flips to fail-closed refusal (the `parse-error` ⇒ * > `RLS_DENY_FILTER` path). Implement the flip as a single dated switch (a * > named const, default = rc-grace) so GA needs a one-line change. * * ## The flip * * **Intended flip point: the v17.0.0 GA release** — i.e. when * `packages/formula/package.json`'s version leaves `17.0.0-rc.x`. Flipping is * exactly one line: * * ```ts * export const CEL_PUSHDOWN_LIMITS_MODE: CelPushdownLimitsMode = 'fail-closed'; * ``` * * `cel-to-filter-limits.test.ts` is written to go red on that line so the flip * cannot be a silent one, and its blast radius is known: flipping the const * fails exactly that file's "the shipped default is the rc grace window" * assertion and its `switch = rc-grace` block — 10 tests, measured — and * nothing else in the repo. The GA expectation they become is already written * out and passing in the same file's `switch = fail-closed` block, and on the * RLS path in `plugin-security`'s `rls-pushdown-limits.test.ts`. So the flip is: * this one const, that one default assertion, and deleting the grace block * whose behaviour has ended. * * Nothing else needs to move at GA. In particular `@objectstack/lint`'s two * enforceability gates need no edit: `validateRlsPredicateEnforceability` reads * `isSupportedRlsExpression` and `validateSharingRuleEnforceability` reads * `compileCelToFilter`, both of which are downstream of this switch, and both * lint suites pin "the lint verdict IS the consumer's verdict" in both * directions — so authoring-time reporting flips with the runtime, by * construction, and cannot drift from it. * * ### The third gate is NOT downstream of this switch — and that is fine (#7073) * * "Nothing else needs to move" is the right conclusion but the two-gate list * above is not the whole set. A **third** lint gate reaches the same * `sharingRules[].condition` field: `validateStackExpressions`, which goes * through ADR-0032's shared `validateExpression` → `celEngine.compile`. That * path applies {@link DEFAULT_LIMITS} **unconditionally** and never reads this * switch (measured on #6833: `celPushdownLimitsMode` appears nowhere in * `validate.ts` or `cel-engine.ts`'s compile path), so it is mode-agnostic by * construction rather than by oversight. * * The consequence, stated plainly so the next reader does not "discover" it as * a bug: **during the grace window lint is STRICTER than the runtime.** An * over-budget `condition` is a gating lint ERROR today, while the pushdown path * still compiles it under `rc-grace`. That divergence runs in the tightening * direction — the author is told at authoring time about a source that will be * refused at GA — and it **self-heals at GA**, when the runtime catches up to * the position lint already holds. #6833's measurement graded it benign on * exactly those grounds. Loosening lint to chase the grace window would be a * regression, not a fix: it would restore the silent acceptance #6132 closed. * * So the GA checklist is unchanged. What #7073 corrected on that third gate is * the message's PRESCRIPTION, not its verdict: an over-budget expression used * to be told "predicates are bare CEL", the dialect trailer, which sends the * author to rewrite a dialect that was never wrong. */ /** How the pushdown path answers a source that overruns a `DEFAULT_LIMITS` bound. */ type CelPushdownLimitsMode = /** 17.0.0-rc.x: compile it anyway (unbounded parse) and WARN, naming the limit. */ 'rc-grace' /** v17 GA: refuse it — `parse-error`, which the RLS path turns into `RLS_DENY_FILTER`. */ | 'fail-closed'; /** * **The one line to flip at v17.0.0 GA.** See this module's docblock for why it * exists, what flipping it changes, and which tests go red when it moves. */ declare const CEL_PUSHDOWN_LIMITS_MODE: CelPushdownLimitsMode; /** * The mode in force for this process. Every read of the switch goes through * here so a test can drive BOTH positions of a dated switch in one suite — * the alternative is a switch whose other half is only ever proven by reading * it, which for a fail-closed security path is not proof. */ declare function celPushdownLimitsMode(): CelPushdownLimitsMode; /** * **Test seam. Production code never calls this** — the only callers are the * suites that pin both positions of the switch (in `@objectstack/formula` and * in `@objectstack/plugin-security`, which owns the `RLS_DENY_FILTER` outcome * and therefore has to see a real over-limit predicate reach it). * * Returns a restore function; call it in `afterEach` so a suite cannot leak its * mode into the next file. * * It is deliberately a runtime seam rather than a module mock because the * outcome under test spans package boundaries: `plugin-security` consumes the * BUILT `@objectstack/formula`, so mocking a formula-internal module from there * is not possible, and mocking `compileCelToFilter` itself would replace the * very function whose refusal is the thing being pinned. * * It is not a security hole worth guarding: it can only be reached by code * already executing in-process, and the direction it can move the switch during * the grace window (`'rc-grace'`) is the behaviour this release ships anyway. */ declare function setCelPushdownLimitsModeForTests(mode: CelPushdownLimitsMode): () => void; /** * Recognize whether an RLS `using` / `check` expression matches one of the SHAPES * the compiler can compile (equality against a `current_user.*` var, equality * against a string literal, set-membership against a `current_user.*` array, or * the `1 = 1` allow-all). This is SHAPE-only — it does not check whether the * referenced context variable is populated at runtime. * * ADR-0056 D4: exposed so an authoring-time gate (`objectstack compile`) can REJECT * a predicate the runtime would silently drop — the class of bug where * `owner == current_user.name` (`==`, unsupported) compiled to nothing and left an * object unprotected. A `false` here means "this predicate will never enforce". * * That gate exists as of #4983: `validateRlsPredicateEnforceability` in * `@objectstack/lint` calls THIS function on every * `permissions[].rowLevelSecurity[].using` / `.check`, so the sentence above is * no longer aspirational. Until then the function had no non-test consumer * anywhere — a declared-but-never-read helper written to fix * declared-but-never-read. */ declare function isSupportedRlsExpression(expression: string): boolean; /** * @deprecated Transitional bridge (ADR-0058 D1). Canonical RLS predicates are * CEL; this exists ONLY so stored/legacy SQL-ish `using`/`check` keeps compiling * until it is migrated. Bridge the legacy SQL subset to canonical CEL so it flows * through the ONE compiler: `=` → `==`, `IN` → `in`. Quoted string literals are * left untouched. It is IDEMPOTENT on CEL input (a `==`/`in` predicate is * unchanged), so authored-CEL seeds pass through as no-ops (no deprecation warn). Only this historically-supported subset is bridged — compound * predicates should be authored in canonical CEL (`&&` / `||`); anything outside * the subset (subqueries, SQL `AND`/`OR`, `LIKE`) stays unparseable and so fails * closed, exactly as before. */ declare function sqlPredicateToCel(expression: string): string; /** * matchesFilterCondition — evaluate a Mongo-style {@link FilterCondition} against * ONE in-memory record (ADR-0058 D4/D6). * * This is the third backend for the canonical filter shape, completing the * round-trip: `compileCelToFilter` lowers CEL → FilterCondition; the engine runs * it as a `where`; `read-scope-sql` lowers it to SQL; and THIS evaluates it * against a single record for write-side validation — the RLS `check` clause * (post-image of an insert/update), where there is no query to push down to. * * Security posture: **fail closed.** Anything it cannot evaluate — a malformed * node, an unknown operator, a nested relation object a flat record can't * satisfy — returns `false` (the write is denied), never `true`. The operator * vocabulary mirrors `read-scope-sql.ts` so the in-memory and SQL backends agree. * * ## The unknown-operator posture: silent `false`, DECIDED not inherited (#6520) * * The #6993 census measured that this face answers an operator it does not know * with a silent `false` — no throw, no `code`, no message — where the other five * JS evaluation faces (`driver-memory`'s three surfaces, `driver-mongodb`, * objectql's `having`) all REFUSE with `INVALID_FILTER` / 400. #6520 was asked * to decide whether to keep that or upgrade it, and KEPT it. The reasons, in the * order they carry weight: * * 1. **The direction of the error is opposite here.** Those five faces compile * READ predicates, where dropping a constraint WIDENS the result set — on an * RLS read scope that is a permission bypass (#3948), so they must be loud. * This one evaluates a WRITE-side `check`: an unevaluable condition denies the * write. Silence costs a diagnostic, not a boundary. * 2. **Callers depend on this being TOTAL.** `plugin-security`'s `explain-engine` * calls it per record to answer "does THIS row satisfy the filter?", and * several driver doubles use it as a list filter. Throwing turns a per-record * verdict into an aborted operation for those callers — a real behaviour * change on the read/explain path, which is not what a `$icontains` parity PR * should be deciding. * 3. **The measured defect is gone without it.** The census's actual complaint * was that a spec-DECLARED operator (`$icontains`) got the silent `false`. * Every operator in `FILTER_OPERATORS` now has an arm in {@link evalOp}, so * the silent answer is reachable only for a name the protocol does not * declare or has retired. [#7536] That claim is maintained rather than * merely inherited: `$like` / `$ilike` are DECLARED by * `StringOperatorSchema` while deliberately staged out of * `FILTER_OPERATORS`, and they got arms here in the PR that declared them — * the test is "can an author write it", not "is it in the allowlist", and by * that test a silent `false` for `$like` would have been the same defect * under a new name. * * What stays open, deliberately and on the record: a RETIRED spelling * (`$regex` / `$options`) still gets the silent `false` here while the other five * faces print `RETIRED_FILTER_OPERATORS`' prescription naming `$icontains`. That * is the residue of #4706's second indictment on this face. It is a narrower * question than the one this section answers and it changes an accept/reject * surface, so #6520 left it to the maintainer rather than folding it into a * parity PR. * * ONE shape is refused instead of answered (#5240): `{ field: {} }`, a field * constrained by zero operators, throws `INVALID_FILTER` rather than returning * `false`. It is the shape the four backends could not agree on, so no answer * here is defensible; the operation fails, which is the #4775 posture for a * `check` that cannot be evaluated. Note this is not merely a louder denial: * where such a constraint sat under an `$or` beside a satisfied branch, or under * a `$not`, the old `false` was ABSORBED and the write was allowed. Those writes * now fail. See {@link emptyFieldConstraintError}. */ /** True iff `record` satisfies `filter`. A null/empty filter matches everything. */ declare function matchesFilterCondition(record: Record, filter: FilterCondition | null | undefined): boolean; /** A call to a name the evaluation environment does not register. */ interface UnknownFunctionCall { /** The called name, e.g. `totallyBogusFn` — for a receiver call, the METHOD name. */ name: string; /** * The engine's own one-line verdict, quoted rather than paraphrased * (`found no matching overload for 'totallyBogusFn(int, int)'`). Consumers * report this verbatim so the publish-time wording and the runtime fault read * as one system. */ detail: string; } /** * The first call in `source` naming a function the evaluation environment does * not register, or `null` when there is none. * * `null` is the answer for every other outcome as well — a source that parses * and type-checks, one the front end refuses for syntax or size, and one * `check()` rejects for any reason that is not an unresolvable call. A caller * gets an existence verdict or nothing; it never has to grade a fault itself. * * Deliberately offers **no suggestion**. Ruling refinement 2: * 「不给 `nearestName` 建议。」 — `nearestName('can', )` * answers `'min'`, a confident jump from a permission verb to a numeric * function, and an author who takes it (an LLM author above all, following the * last sentence it was handed) is further from working than before it asked. */ declare function firstUnknownFunctionCall(source: string): UnknownFunctionCall | null; /** * Shared expression validator (ADR-0032 §Decision 1/5). * * One validator, used by every author surface — `objectstack build`, * `registerFlow`/metadata registration, and the agent-callable * `validate_expression` tool — so a malformed expression is caught the same * way everywhere, with a message written for **self-correction** (Decision 1d): * it states what is wrong AND the correct form. * * Field roles map to dialects (Decision 2): * - `predicate` → bare CEL returning bool (`record.rating >= 4`) * - `value` → bare CEL of any type (`daysFromNow(3)`) * - `template` → text with `{{ path }}` holes (`Hot lead: {{ record.name }}`) * * The #1 author error (human or LLM) is wrapping a field reference in single * `{…}` braces inside a CEL field — `{x}` parses as a CEL map literal and fails. * This validator detects that specific mistake and returns the exact fix. */ type FieldRole = 'predicate' | 'value' | 'template'; /** * Loose input accepted by the validator: a bare string, or any object exposing * `dialect`/`source` (the Expression envelope, or a not-yet-narrowed value from * a `config.condition` / `edge.condition` field). Kept structural so call sites * need not pre-narrow to the strict {@link Expression} dialect union. */ type ExprInput = string | { dialect?: string; source?: string; } | null | undefined; /** Optional schema context for field-existence checks (Decision 1b, v1). */ interface ExprSchemaHint { /** Object the expression is authored against (for error text). */ objectName?: string; /** Known top-level field names, so `record.` can be checked. */ fields?: readonly string[]; /** * #1928 tier 4 — field name → spec field type (`'text'`, `'currency'`, * `'boolean'`, `'date'`, …). Enables the advisory type-soundness check: a * text or boolean field used with an arithmetic/ordering operator against a * number faults at runtime and the expression silently evaluates to `null`, * so it is surfaced as a NON-blocking warning. Absent ⇒ the check is skipped. * Only consulted for `scope: 'record'` sites (where refs are `record.`). */ fieldTypes?: Readonly>; /** * Evaluation scope of the authoring site — determines whether a bare top-level * identifier is legal (#1928): * - `'record'` → the record is bound only as the `record` namespace, with * no field flattening (`Field.formula`, object validation * predicates). A bare `amount` resolves to nothing and the * expression silently evaluates to `null` / never fires, so * it MUST be written `record.amount`. We flag bare refs. * - `'flattened'` → the record's own fields are spread to top-level alongside * flow variables (flow / automation conditions), so bare * `status` is correct and is NOT an error. Flow variables * are not schema-knowable, so a non-field bare identifier * can't be soundly told apart from a typo — but when one is * a near-miss of a known field we emit a non-blocking * did-you-mean *warning*. (Default.) */ scope?: 'record' | 'flattened'; /** * ADR-0068 D4 — the closed catalog of valid role names (built-in + declared). * When supplied, a role-membership predicate testing a role NOT in this set * (e.g. `'org_admni' in current_user.positions`) is flagged as an error. Closes * the AI-hallucination hole where a model invents a plausible-but-nonexistent * role that then silently never matches. Absent => role checks are skipped. */ roleCatalog?: readonly string[]; } interface ExprValidationError { /** Self-correcting message: what is wrong + the correct form. */ message: string; /** The offending source, echoed for location. */ source: string; } interface ExprValidationResult { ok: boolean; errors: ExprValidationError[]; /** * Non-blocking advisories (#1928 tier 3): a likely-typo'd field reference in a * flattened flow condition. Never affects `ok` — callers surface these without * failing the build, since a bare identifier there may legitimately be a flow * variable. */ warnings: ExprValidationError[]; } /** The dialect a field role expects (Decision 2). */ declare function expectedDialect(role: FieldRole): 'cel' | 'template'; /** Cheap edit-distance suggestion for typo'd field names. */ /** * The closest candidate to `name`, or `undefined` when nothing is close enough * to be worth suggesting. * * The public face of the same edit-distance heuristic this module already uses * for unknown field refs and unknown roles, exported so other "did you mean?" * diagnostics reuse one threshold instead of each inventing their own — an * author (increasingly an agent) should not get a suggestion here and silence * there for the same class of typo. */ declare function nearestName(name: string, candidates: readonly string[]): string | undefined; /** * Validate one expression for a given field role. Never throws — returns a * structured result. Call sites decide whether to throw (build/registration) * or report (agent tool). * * "Never throws" is the contract, and it now holds for the one input that used * to break it: an envelope whose `source` is not a string is refused through * `errors[]` like any other malformed expression, so the caller's located * reporting survives to the author. See {@link toSource}. */ declare function validateExpression(role: FieldRole, input: ExprInput, schema?: ExprSchemaHint): ExprValidationResult; /** * Introspect what an author (esp. an agent) may use in a field (Decision 1e): * the expected dialect, the in-scope field references, and the callable * functions. Feeds the authoring context so the model does not guess. */ declare function introspectScope(role: FieldRole, schema?: ExprSchemaHint): { dialect: 'cel' | 'template'; fields: string[]; roots: string[]; roles: string[]; functions: string[]; }; /** * Coarse value categories a `value`/formula expression can compute. `'unknown'` * means cel-js could not prove a concrete type — either a `dyn` result (an * ambiguous expression over untyped operands) or one that does not type-check. */ type InferredValueType = 'number' | 'text' | 'boolean' | 'date' | 'unknown'; /** * Infer the coarse value type a `value`/formula expression computes — `'number'`, * `'text'`, `'boolean'`, `'date'`, or `'unknown'` when cel-js cannot prove a * concrete type. `schema.fields` (the host object's field names) are declared so * a bare `` reference resolves the same as `record.`. * * The motivating use is measure-eligibility: a dataset derives a SUM measure for * a `formula` field ONLY when this returns `'number'`, so an ambiguous or * non-numeric formula never yields an incoherent measure. Conservative by * construction — see {@link inferCelType}. */ declare function inferExpressionType(input: ExprInput, schema?: ExprSchemaHint): InferredValueType; /** * Public catalog of CEL functions available in expressions — what `introspectScope` * advertises to authors (incl. AI). Every entry MUST actually resolve at runtime: * either registered in `registerStdLib` or a verified cel-js built-in. Drifting this * list ahead of the runtime tells the author to call functions that fault (#1928). * * ## This is a CURATED SUBSET of what the environment resolves — by construction * * The evaluation `Environment` resolves **72** distinct function names. This list * carries 35 of them, and the 37-name gap is NOT staleness. Measured decomposition * (`cel-stdlib-drift.test.ts` re-measures all four numbers on every run): * * 72 registered names * = 39 callable BARE, as `fn(x)` -> the only shape this list may carry * + 33 callable only on a RECEIVER, `x.fn()` -> structurally ineligible * * 39 bare-callable * = 27 added by `registerStdLib` -> ALL advertised (one per registration site) * + 8 cel-js built-ins -> advertised: has size int string bool double * timestamp duration * + 4 cel-js built-ins WITHHELD -> bytes dyn type uint * * **Bare-callability is the membership rule, and it is load-bearing.** Every * consumer spends an entry as a bare call: objectui's Studio predicate editor * inserts a suggestion verbatim as `` `name(` `` (`CelPredicateField.tsx`), and the * runtime drift guard in `cel-engine.test.ts` probes each entry with a bare-call * expression. So the 33 receiver-only names cel-js registers (`s.split(',')`, * `list.map(...)`, `ts.getFullYear()`, `opt.orValue(...)`) can never appear here: * flattening them into this list would autocomplete `split(` into an author's * predicate, which faults `no matching overload`. Widening this list to "every * registered name" is therefore not the safe direction — it is a broken one. * * The 4 withheld bare-callables are CEL's remaining type-conversion/introspection * primitives. They resolve today; they are withheld as an authoring decision (no * measured demand, and `dyn`/`uint`/`bytes` mostly widen the ways an AI author can * emit something unusable), not because they are unavailable. That withholding is * a declared ledger in the drift pin, so adding one is a deliberate edit and a new * cel-js built-in cannot arrive unnoticed. * * ⛔ This list is NOT an oracle for rejecting unknown functions. A gate that * rejects what is absent here would reject 37 names that resolve and evaluate * today. The unknown-function verdict belongs to the engine's own `check()` * (ruling on #13594); `@objectstack/lint` uses that and never reads this list. */ declare const CEL_STDLIB_FUNCTIONS: string[]; export { CEL_PUSHDOWN_LIMITS_MODE, CEL_STDLIB_FUNCTIONS, type CelAstNode, type CelBoundsOverrun, type CelFilterCompileOptions, type CelFilterCompileResult, type CelFilterFailReason, type CelLimitKey, type CelParseResult, type CelPushdownLimitsMode, DEFAULT_LIMITS, type DialectEngine, type EvalContext, type EvalError, type EvalResult, type ExprInput, type ExprSchemaHint, type ExprValidationError, type ExprValidationResult, ExpressionEngine, type FieldRole, type InferredValueType, type ParseCelToAstOptions, SCOPE_ROOTS, type SeedPrimitive, type SeedValue, TEMPLATE_FORMATTERS, type UnknownFunctionCall, __resetPushdownLimitWarnings, buildScope, celEngine, celPushdownLimitsMode, collectCelRootIdentifiers, compileCelToFilter, cronEngine, expectedDialect, firstUndeclaredReference, firstUnknownFunctionCall, formatValue, getEngine, hasDialect, inferExpressionType, introspectScope, isPushdownableCel, isSupportedRlsExpression, lowerCelAst, matchesFilterCondition, nearestName, normalizeExpression, normalizeExpressionTree, parseCelToAst, parseCelToAstWithReason, register, registerStdLib, resolveSeed, resolveSeedRecord, setCelPushdownLimitsModeForTests, sqlPredicateToCel, templateEngine, validateExpression };