import { Expression, ExpressionInput } from '@objectstack/spec'; import { EffectiveObjectPermission } from '@objectstack/spec/security'; import { Environment, ASTNode } from '@marcbachmann/cel-js'; import { FilterCondition, CrossFieldComparisonVerdict, CrossFieldComparisonFieldMeta } 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. */ /** * The acting subject's effective object permissions, indexed by object name — * the {@link EvalContext.permissions} payload, and the only input `can()` reads. * * **It is the published `/auth/me/permissions` shape, verbatim**: the `objects` * map of `GetEffectivePermissionsResponse`, object name → the server-resolved * `EffectiveObjectPermission` for this subject. One contract, both ends — the * server already publishes it, a caller carries it in here unchanged, and * nothing re-derives, re-keys or re-shapes it on the way. * * **A pure DATA map, never a resolver.** ⛔ Not a callback, not a lazy getter, * not an object carrying methods: a function here would make evaluation depend * on something outside the context — both the "declared, never bound" shape * that cost `os.exists` / `os.count` / `os.lookup` their place on this * interface, and a breach of the purity invariant `stdlib.ts` documents, the * one that keeps `objectstack build` artifacts byte-stable across runs. * * **Completeness is the caller's promise.** `can()` reads an ABSENT object * entry as "no grant" — which is what an all-`false` entry means anyway — so a * partial map does not fault, it answers `false`. Pass the whole effective set * the endpoint returned, never a hand-picked subset. */ type EvalPermissions = Readonly>; /** * 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. * * **It carries data, never a query API.** `buildScope()` binds exactly the * fields declared here, so an expression reads only what its call site already * passed in — reaching a row this context does not carry is outside the * contract. A kernel API (`os.exists` / `os.count` / `os.lookup`) was declared * here and never bound by `buildScope()`, and it is removed rather than * implemented because the harm came from the declaration existing, not from * the implementation missing: a predicate written to it faulted at runtime * (`found no matching overload for 'dyn.lookup(string, dyn)'`), and an * unevaluable predicate refuses the write it guards — so a validation rule * authored against the declaration locked every write on its object. */ 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; /** * The acting subject's effective object permissions — see * {@link EvalPermissions} for the shape and where it comes from. * * Read by exactly one binding, `current_user.can(object, verb)`, and bound * only when {@link user} is also present: `can` is a question about the * acting subject, and this map is that subject's answer sheet. ⛔ It is NOT * mounted as a CEL variable — an authored predicate cannot read * `os.permissions.crm_lead.allowEdit` and reach around the verb vocabulary, * so the closed verb table stays the only door. * * **Absent ≠ empty.** With no map at all `can()` THROWS * (`ok: false, kind: 'runtime'`) rather than answering: a context that was * never given permission data cannot distinguish "denied" from "nobody * passed the data", and answering either way — `true` fail-open, or a silent * `false` — turns a wiring bug into a security verdict. An EMPTY map is a * real answer (this subject holds nothing) and evaluates to `false`. */ permissions?: EvalPermissions; /** 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; }; /** * ObjectStack standard CEL function library. * * Registered into the per-evaluation `Environment` by the CEL engine. All * functions are pure given a pinned `now` and the pinned data this module is * handed — that determinism is what makes `objectstack build` artifacts * byte-stable across runs. ⛔ Nothing registered here may be handed a resolver, * a lazy getter or any other callback into the host: a function that reaches * outside its arguments and the data pinned at registration makes the same * source evaluate to different values on two runs of the same build. * * 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}. The one RECEIVER-form function this library registers — * `current_user.can(object, verb)` — uses that same binding rather than * fighting it: see {@link registerPermissionPredicate}. */ /** * What `current_user.can(object, verb)` is answered from: the acting subject * this evaluation bound, and that subject's effective object permissions. * * Both halves are DATA, pinned when the environment is built. `subject` is the * very object {@link buildScope} mounted under `current_user` (and its `user` / * `ctx.user` / `os.user` aliases), carried here so the binding can tell a call * ON the acting subject from a call on something else that happens to sit to * the left of a dot. */ interface PermissionBinding { /** * The canonical `EvalUser` this evaluation bound, or `undefined` when the * evaluation carries no user at all (a parse-time environment, a system * write). Compared by IDENTITY, never by shape. */ readonly subject: unknown; /** The subject's effective object permissions, or `undefined` when none were passed. */ readonly permissions: EvalPermissions | undefined; } /** * Register `can` — the permission predicate — as a RECEIVER method, so the one * authored spelling is `current_user.can(object, verb)`. * * ## Receiver-only, deliberately * * cel-js binds dotted names to receiver types, and a bare `can(object, verb)` * is NOT registered: it keeps faulting (`found no matching overload for * 'can(dyn, dyn)'`), which is the right answer — a bare call names no subject, * and a permission question with no subject has no meaning. The name exists in * the environment either way, so the publish gate's function-existence verdict * (`firstUnknownFunctionCall`) reads a bare call as a call-FORM fault rather * than an existence one, exactly as it already does for `split`. * * ## Every refusal is LOUD — there is no quiet answer * * The binding throws, and the engine reports the throw as * `{ ok: false, error: { kind: 'runtime' } }`, for each of: * * - **no permission data in the context.** ⛔ Never `true` (fail-open: an * action shown to someone who cannot use it, and worse, a section of data * revealed), ⛔ never a silent `false` (fail-shut: every gated element * disappears for everyone, indistinguishable from a correct denial, and the * author is told nothing). A context that was never given the data cannot * tell "denied" from "nobody passed it", so it says so. * - **a receiver that is not the acting subject.** `record.can(…)` reads as a * question about the record and would silently be answered about the user. * - **a verb outside the vocabulary.** `current_user.can('crm_lead', 'approve')` * is an author asking about a capability this platform does not model; the * refusal names the whole accepted vocabulary. * - **a non-string object or verb.** * * The ONE quiet answer is a real one: an object the effective map does not * mention is an object with no grant, and answers `false` — the same answer an * all-`false` entry gives, because they mean the same thing. */ declare function registerPermissionPredicate(env: Environment, binding: PermissionBinding | undefined): Environment; /** * 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); `permissionBinding` is pinned the * same way and for the same reason — see {@link PermissionBinding}. * Implementations are kept tiny and dependency-free — they're the contract * surface for AI authors and must stay legible. * * `can` is registered UNCONDITIONALLY, binding or not: whether a name exists in * this environment is a fact about the platform, not about one call site's * data. A conditional registration would make the publish gate's answer depend * on which context happened to build the environment, so an authored predicate * could pass the gate and be unknown at runtime — the very split this binding * exists to close. */ declare function registerStdLib(env: Environment, now: () => Date, timezone?: string, permissionBinding?: PermissionBinding): 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; /** * 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'; /** * Binding roots THIS authoring surface mounts **beyond** the platform * baseline (`SCOPE_ROOTS`) — the surface naming the roots it binds, so the * validator keeps refusing every other one instead of standing down. * * ## What it is for * * `scope` answers "are the record's fields flattened here?". It cannot answer * "what else is mounted here?", and some surfaces mount something else. A * page component's `visibleWhen` is the worked case: `page.zod.ts`'s describe * states its contract-bound roots as `record`, `current_user` **and page * state as `page.`**, and ends with the example `page.selectedProjectId * != ''`. `page` is not in the platform baseline and has no business being * there — a hook condition or a validation rule binds nothing of the sort — * so before this key the two faces of `scope` were the only choices, and * neither is correct for that surface: * * - `'record'` refuses `page.selectedProjectId != ''` — the spec's own * worked example — and prescribes `record.page`, which * names nothing on any layer; * - `'flattened'` accepts it, and accepts a bare `status` with it, which is * the narrowing the surface wanted in the first place. * * Declaring `roots: ['page']` keeps the narrowing and stops the false * refusal: `page.selectedProjectId != ''` resolves, `status == 'done'` is * still the hard error it should be. * * ## Direction — this key only ADDS, and that is deliberate * * A root listed here is declared alongside the baseline, never instead of it: * passing `roots` can only turn a refusal into an acceptance, never the * reverse, so a caller that adopts it cannot silently lose a check it has * today. The opposite direction — a surface that binds a **closed** set and * must refuse a baseline root (`os`, `vars`) it never mounts — is NOT this * key's job and must not be bolted onto it: that surface says so with * `collectCelRootIdentifiers`, which reads the AST and is independent of any * declaration here. `SCOPE_ROOTS`'s own docblock is the authority on why * those are two mechanisms rather than one. * * ## Shape * * Plain declarative data — a list of names the caller already knows, not a * resolver callback the validator would have to invoke. `@objectstack/lint`'s * view/page gate already supplies exactly such a list to * {@link firstUndeclaredReference} (`VIEW_PAGE_EXTRA_ROOTS`); this key is the * same vocabulary reaching the shared validator, so a surface can declare its * roots without dropping out of `validateExpression` — and losing the compile * check, the braces hint, field existence, the role catalog and the * type-soundness pass with it. */ roots?: readonly string[]; /** * 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[]; /** * [#18682] Opt IN to the relationship-traversal conflict checks. * * ⭐ Deliberately opt-in rather than derived from `fieldTypes`, because the * prescription these checks hand out is only TRUE where something hydrates. * `record..` resolves the related record at exactly * one seam — the object validation rules (`script` / `cross_field`) evaluated * by ObjectQL's `checkPredicate`. Nothing hydrates at a field `requiredWhen` / * `readonlyWhen`, an option `visibleWhen`, an action's `visible` / `disabled`, * a sharing-rule (RLS) condition, a hook condition, a flow node or edge * condition, or a formula `value`. Telling an author at one of those sites to * "write `record.account.id` instead" produces an expression that faults at * evaluation — and on the fail-OPEN seams among them, a faulting predicate * stops enforcing altogether. So the refusal would trade a working rule for a * dead one, at nine seams the traversal capability never covered. * * Set it only where the traversal is actually served. Absent ⇒ the checks do * not run, which is every caller's behaviour until it opts in. */ traversalHydration?: boolean; } /** * One refusal (in `errors[]`) or advisory (in `warnings[]`). * * `code` and `params` say the same thing as `message`, for a consumer that * renders its own words: `code` is one of the closed set in * `expression-refusal.ts` and `params` are the values `message` interpolates. * `message` is the English sentence, unchanged. */ type ExprValidationError = { /** Self-correcting message: what is wrong + the correct form. */ message: string; /** The offending source, echoed for location. */ source: string; } & ExpressionRefusal; 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. * * A surface that declares `schema.roots` gets those roots ADVERTISED here as * well as accepted by {@link validateExpression}. The two faces are fed from * one declaration on purpose: a root the validator accepts but this list never * names is a spelling the author has no way to discover, and a second hand-kept * list is how the two drift apart. */ 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[]; /** * The closed set of refusal CODES this package's expression producers emit — * `validateExpression`'s `errors[]` / `warnings[]` and * `collectCelRootIdentifiers`'s failure arm — each with the typed PARAMS its * English `message` interpolates. * * ## Why a code beside the message * * The `message` is written for self-correction (ADR-0032 §Decision 1d) and it * is English: an API caller, the CLI and the `validate_expression` agent tool * read it as it is. A localized author surface cannot translate a sentence it * does not own, and matching the English with a pattern would bind the consumer * to wording this package rewrites whenever a prescription improves. So every * refusal also carries a stable `code` and the variable parts as `params`, and * a consumer keys its own catalogue row to the code and fills the row from the * params. The `message` is unchanged by this: it is the same sentence, byte for * byte, that the refusal carried before the code existed. * * ## The contract * * - A code names ONE message template. Two sentences that differ in more than * an optional clause are two codes, so a catalogue row never has to branch * on a param to pick its wording. Optional clauses (a did-you-mean, an * object name) are optional params: present exactly when the English * message carries that clause. * - Codes are kebab-case and never change once published. A new refusal is a * new code; a reworded message keeps its code. * - Params are the interpolated values, not their rendering: a list is an * array (the consumer joins it), a quoted reference is the bare text (the * consumer quotes it), and a phrase chosen from a closed set is a token. * - `detail` is a diagnostic the CEL or template engine wrote — English, and * for CEL with an excerpt of the source. It is passed through as it is; * this package does not own its wording. * * ⛔ Not an ADR-0112 error code. These are authoring diagnostics delivered as a * function's return value, never a failing request's `error.code`, which is * why they are kebab-case and not SCREAMING_SNAKE. */ /** The CEL-dialect field roles — every role but `template`. */ type CelFieldRole = Exclude; /** * What a non-text envelope `source` was, as a token. The message renders it * with its article (`an array`, `a number`). */ type ExpressionSourceKind = 'array' | 'object' | 'number' | 'boolean' | 'bigint' | 'symbol' | 'function'; /** Code → the params its message interpolates. The keys ARE the closed set. */ interface ExpressionRefusalParams { /** An envelope whose `source` is present but is not text. */ 'envelope-source-not-text': { readonly role: FieldRole; readonly found: ExpressionSourceKind; }; /** A `template` slot holding an envelope of another dialect. */ 'template-dialect-mismatch': { readonly dialect: string; }; /** The template engine refused the text; `detail` is its reason. */ 'invalid-template': { readonly detail: string; }; /** A single-brace `{ref}` where a template hole needs `{{ ref }}`. */ 'template-single-brace': { readonly ref: string; }; /** A CEL slot holding an envelope of another dialect. */ 'cel-dialect-mismatch': { readonly dialect: string; }; /** The CEL engine refused the source; the prescription is "write bare CEL". */ 'invalid-cel': { readonly role: CelFieldRole; readonly detail: string; }; /** * Valid CEL over one of the platform's parse budgets. `limit` and * `limitValue` are present together when the bound is known, and absent * together when it is not. */ 'cel-too-large': { readonly role: CelFieldRole; readonly detail: string; readonly limit?: CelLimitKey; readonly limitValue?: number; }; /** * A bare-callable function written as a method (`record.name.upper()`). * `receiver` is the plain dotted chain it was called on, when one can be * lifted from the source. */ 'cel-method-call': { readonly role: CelFieldRole; readonly detail: string; readonly name: string; readonly receiver?: string; }; /** A call to a name that is not callable here; `suggestion` is the nearest advertised one. */ 'cel-unknown-function': { readonly role: CelFieldRole; readonly detail: string; readonly name: string; readonly suggestion?: string; }; /** The CEL engine refused a source that holds a template brace `{ref}`. */ 'cel-template-brace': { readonly role: CelFieldRole; readonly detail: string; readonly ref: string; }; /** `record.` naming no field of the object. */ 'unknown-field': { readonly field: string; readonly objectName?: string; readonly suggestion?: string; }; /** A role-membership test naming a role outside the catalog; `catalog` is every valid role. */ 'unknown-role': { readonly name: string; readonly suggestion?: string; readonly catalog: readonly string[]; }; /** `name.…` one typo away from a root the surface binds; `roots` are the surface's declared roots. */ 'unbound-root': { readonly name: string; readonly roots: readonly string[]; readonly suggestion: string; }; /** A bare identifier in a `record`-scoped expression. */ 'bare-reference': { readonly name: string; }; /** * Arithmetic on a date field against a number. `operands` are the CEL * operand types; `reference` is the field as written, when it is known. */ 'date-arithmetic': { readonly operands: string; readonly reference?: string; }; /** * WARNING, never an error: a text or boolean field used with an arithmetic * or ordering `operator` against a number. */ 'type-mismatch': { readonly operands: string; readonly operator: string; readonly held: 'text' | 'boolean'; readonly reference?: string; }; /** * WARNING, never an error: a bare identifier in a flattened condition that * is one typo away from a field. Absent `objectName` reads "the trigger object". */ 'field-near-miss': { readonly name: string; readonly suggestion: string; readonly objectName?: string; }; /** A reference field read both through the relationship and as a plain value. */ 'traversal-bare-and-traversed': { readonly root: string; readonly field: string; }; /** A reference field read through more than one relationship hop. */ 'traversal-multi-hop': { readonly root: string; readonly field: string; }; /** `collectCelRootIdentifiers` was handed no expression. */ 'empty-expression': Readonly>; /** `collectCelRootIdentifiers` could not parse the source; `detail` is the parser's reason. */ 'cel-parse-failed': { readonly detail: string; }; } /** Every refusal code this package emits. */ type ExpressionRefusalCode = keyof ExpressionRefusalParams; /** * One refusal's `code` and `params`, correlated: narrowing on `code` narrows * `params`. `Codes` restricts it to the codes one producer emits. */ type ExpressionRefusal = { [Code in Codes]: { readonly code: Code; readonly params: ExpressionRefusalParams[Code]; }; }[Codes]; /** The codes `collectCelRootIdentifiers` emits. */ type CelRootsRefusalCode = 'empty-expression' | 'cel-parse-failed'; /** The codes `validateExpression` emits, in `errors[]` and `warnings[]`. */ type ExprValidationCode = Exclude; /** * The closed set, as a value — for a consumer that must prove it has a * catalogue row for every code, and for this package's own exhaustiveness pin. */ declare const EXPRESSION_REFUSAL_CODES: readonly ExpressionRefusalCode[]; /** * 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; }; /** * The "never faults" BASELINE: the namespace roots the strict check env * declares, so that a `record`-scoped CEL site is never faulted for merely * NAMING one. ⛔ It is NOT any surface's accept set — membership here is not a * claim that a surface BINDS the root, and a root this list declares can be * unbound, and so unresolvable, at any individual evaluation site. * * The PER-SURFACE verdict lives in `@objectstack/lint`, never here: * `fieldRuleRootIssue` answers "may THIS surface reference this root?" for the * field-level `*When` surface, against that surface's own closed set * `FIELD_RULE_BOUND_ROOTS` (`record` / `previous` / `parent`). Ask that pair * whenever the question names a surface. A root this list declares and that * surface does not bind — `data` is the standing example (#17409) — is the two * answers doing their separate jobs, ⛔ not a disagreement to repair by editing * this list. * * Declared so that member access (`record.foo`) and any arithmetic/comparison on * a root both defer 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. * * ⚠️ The CEL type each env declares them AS is not uniform, and the difference is * load-bearing rather than incidental. {@link buildScopedEnv} — the declaredness * oracle — declares them `dyn`, because only `dyn` delivers BOTH halves of the * sentence above; `map` delivered member access and faulted the comparison, and * #16412 measured what that cost (see {@link firstUndeclaredReference}). The * type-soundness envs keep them `map`: there a root is a container the check * deliberately declines to reason through, and the typed struct on * `record`/`previous`/`input` is what carries the field types. * * ## 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. * * ⚠️ {@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`), so a source can compile clean and still * fault HERE. * * ## What is CLOSED, and what is still open (#16412) * * ⭐ CLOSED — the {@link SCOPE_ROOTS} class. It was by far the wider of the two * reachable ones: a root, or an object field sharing a root's name (`data`, * `config`, `result`, `item`, `event`, `input`, `user`, …), used as the operand * of an operator with no `map` overload. {@link buildScopedEnv} declared those * roots `map` while the permissive env left them `dyn`, and that DISAGREEMENT * was the whole mechanism; the roots are now `dyn` in both, so the class cannot * arise. Measured on this env, before → after: * * data == 'x' && status == 'q' null -> "status" the class, closed * status == 'q' && data == 'x' "status" unchanged, the control * * ⛔ STILL OPEN — every OTHER first-error class, and the mechanism above is * untouched for them. Two are reachable from authored input: * * - a CEL TYPE name (`type`, `string`, `int`, …) in that same position. CEL * itself declares those names, so no declaration this package controls can * move them; `type == 'grid' && status == 'q'` still answers `null`. Pinned * per NAME by `@objectstack/lint`'s `visibility-bare-identifier` suite, while * the masking it causes is source-wide. * - `has()` handed a non-select argument (`has(status) && other == 'x'`), which * faults `has() invalid argument`. `@objectstack/lint`'s * `validate-visibility-predicates` masks `has(…)` spans at its own call site * (#16118) and that mask stays load-bearing; no other consumer has one. * * ⇒ A `null` from this helper is still "nothing was reported", never "the * source is clean". ⛔ Do not close the remaining classes 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. * The failure also carries a refusal `code` and its `params` * (`expression-refusal.ts`), for a consumer that renders its own words * instead of the English `error`. */ declare function collectCelRootIdentifiers(source: string): CelRootIdentifiersResult; /** * What {@link collectCelRootIdentifiers} answers: the roots, or a refusal * whose `error` is the English reason and whose `code` / `params` say the * same thing for a consumer that renders its own words. */ type CelRootIdentifiersResult = { ok: true; roots: string[]; } | ({ ok: false; error: string; } & ExpressionRefusal); /** * 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; /** * The inverse of {@link parseCelToAst}: print a CEL AST back to surface syntax. * * This is the lossless half of the #15811 migration. Every EVALUATED expression * slot in the spec now composes `EvaluatedExpressionInputSchema`, so an * `{ dialect: 'cel', ast }` envelope carrying no `source` is refused at the * door — and the author of such an envelope needs a `source` back. For CEL the * platform HAS a printer (cel-js `serialize`, which this package already uses * for its own scope rewrites), so that recovery is mechanical rather than a * re-authoring job. `cron` and `template` have no AST at all, so the question * does not arise for them; ADR-0087's structured TODO covers every case this * function answers `null` to. * * ⚠️ Lossless is claimed about MEANING, not bytes. `serialize` re-renders from * the parse tree, so it normalises what the grammar does not distinguish — * measured: single-quoted string literals come back double-quoted * (`record.p == 'x'` → `record.p == "x"`), and redundant parentheses that the * parser dropped do not come back. A caller that needs the author's original * bytes cannot get them from an AST; a caller that needs a `source` the engine * evaluates identically can. * * Returns `null` — never throws — on anything that is not a CEL AST this * platform can round-trip, so a caller can fall back to the hand-migration path * in one line. Three ways to earn that `null`, and the third is the one that * matters: `serialize` refuses the value (it throws `Unknown AST operation` on * a non-AST, which is how an opaque `ast` an author hand-wrote is caught); the * printed text is blank; or the printed text does not parse back through * {@link parseCelToAst} — the platform's own bounded parser, the same one every * other entry point in this package reaches. That last check is what makes * "lossless" a reading rather than a claim: a source this platform cannot parse * is not a source it can evaluate, whatever the printer produced. */ declare function printCelAst(ast: unknown): string | 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; /** * Build {@link EvalPermissions} from the `objects` map of a * `/auth/me/permissions` response. * * Accepts `unknown` on purpose — the payload usually arrives from the network, * where "it is typed" is a claim about the caller's declaration file rather * than about the bytes. * * @throws when `objects` is not a plain map of object name -> * `EffectiveObjectPermission`. The message names the offending object and what * the schema said about it; there is no lenient arm, no coercion and no * partial result, because half a permission map is the failure this refuses. */ declare function toEvalPermissions(objects: unknown): EvalPermissions; /** The default scope root a record-scoped predicate traverses from. */ declare const DEFAULT_TRAVERSAL_ROOT = "record"; /** * What an expression asks of one scope root: which fields it reads through * (one hop), which it uses as a plain value, and which it reads through more * than one hop. */ interface RelationshipTraversalAnalysis { /** * Field name → the related-record fields the expression names on it. * `record.crm_account.type` yields `crm_account -> { 'type' }`. */ readonly traversals: ReadonlyMap>; /** * Fields the expression uses as a VALUE rather than as a traversal receiver — * `record.crm_account == 'acc_1'`, and also a method receiver such as * `record.name.startsWith('A')`, where the value is what the method reads. */ readonly bareFields: ReadonlySet; /** * Fields the expression reads through MORE than one hop * (`record.a.b.c`). One hop is the declared depth; these are reported so the * authoring layer can refuse them instead of pinning a prefix. */ readonly multiHopFields: ReadonlySet; } /** * Analyse one authored CEL source for the hops it takes through `root`. * * Returns `null` when the source does not parse or blows the platform's bounds * — the caller already has a channel for that verdict (`compile()` / * `validateExpression`) and this function deliberately does not invent a second * one. An expression that parses but names no member of `root` yields an empty * analysis, which is the common case and costs the caller nothing. * * Reads the AST through {@link parseCelToAst} rather than a private * `new Environment()`, so "what parses" has exactly one answer across build, * lint and runtime — and so this analysis can never admit a shape the engine * would refuse to evaluate. */ declare function analyzeRelationshipTraversals(source: string, root?: string): RelationshipTraversalAnalysis | null; /** Why a traversal the expression names cannot be served as written. */ type TraversalConflictKind = /** * The same reference field is both traversed and used as a value. Hydrating * it serves the traversal and silently changes the value comparison, so the * expression is refused rather than served half-right. */ 'bare-and-traversed' /** Read through more than one hop; one hop is the declared depth. */ | 'multi-hop'; /** One refusal-worthy finding about one field. */ interface TraversalConflict { readonly field: string; readonly kind: TraversalConflictKind; /** Author-facing sentence: what is wrong and what to write instead. */ readonly message: string; } /** * The conflicts in an analysis, judged against the caller's knowledge of which * fields are reference-typed. * * `isReferenceField` is a predicate rather than a field table because the two * callers hold that knowledge in different shapes — the authoring layer has * `fieldTypes`, the engine has the object registry — and neither should have to * build the other's structure to ask this question. A field the predicate does * not recognise as a reference is left entirely alone: `record.address.city` on * an object-valued field traverses today and must keep traversing. */ declare function findTraversalConflicts(analysis: RelationshipTraversalAnalysis, isReferenceField: (field: string) => boolean, root?: string): TraversalConflict[]; /** * 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. The ROOT alone (`current_user`) is the whole * context object, never a value: every comparison refuses it (see * {@link variableObjectComparandRefusal}). * * ## One value per comparison (#19886) * Each of the six comparisons (`==` `!=` `>` `>=` `<` `<=`) compares ONE value. * A list operand — a list literal, or a variable that resolves to an array — * is refused whichever side it is on, opposite a field or in a constant * comparison ({@link arrayComparandRefusal}); so are the variable root and a * variable resolving to an object ({@link variableObjectComparandRefusal}). * An `in` list whose member is itself a list is refused too * ({@link listMemberRefusal}). "One of these" is `in`, "none of these" is * `!(… in …)`, and an ordering comparison takes one bound. */ 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. * * [#20444] The claim was FALSE for a while, and is true again because of an * arm, not a rewording. `$empty` was declared by `FieldOperatorsSchema` / * `SpecialOperatorSchema` (#20311) and staged out of `FILTER_OPERATORS` like * `$like`, but its arms were placed in per-lane cards rather than in the * declaring PR — so from that declaration until this face's arm landed, an * RLS `check` written with `$empty` was answered by the silent `false` * below, for every record and both flags: the defect this paragraph names. * It now has its arm in {@link evalOp}, judged by the stored value (this * face's reading, ruling A on #20399). Today the declared-but-staged names * are `$like` and `$ilike` (`$empty` left the staging in #20446), and each * is answered here; a name the * protocol declares NEXT is owed an arm here by the PR that lets an author * write it, or by the lane card its staging names. * * 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}. * * [#19886] A second shape is refused the same way: an ARRAY where a single * comparable value is expected — under `$ne`, or in the equality position * (`{ field: [...] }`, `{ field: { $eq: [...] } }`). It is the one position * where this face's answer was not merely silent but the WRONG way round for a * write gate: a strict comparison never equals an array, so `$ne` matched EVERY * record, and a negated equality (`$not` around `{ field: [...] }`) did too. On * the ADR-0058 D4 `check` that admitted every write the policy was written to * refuse, measured through the real `plugin-security` on three drivers. The * query faces the read side runs on — driver-sql's unbindable-comparand * refusal and driver-memory's array-comparand refusal — already refuse the * shape with `INVALID_FILTER` / 400; this face now gives the same envelope. See * {@link arrayComparandError}. * * [#20355] A third shape is refused when the caller hands over the object's * declared columns ({@link MatchesFilterOptions.fields}): a `{ $field }` * comparison between two columns that share no COMPARISON CLASS — text against * a number, text against a file field, anything against a formula field. The * rule is the spec's `crossFieldComparisonVerdict`, the same one driver-sql's * read applies, so one access policy gets one answer on both sides of the * write. See {@link findCrossFieldClassRefusal}. */ /** * [#20355] What a caller that knows the record's object may tell the evaluator. */ interface MatchesFilterOptions { /** * The declared columns of the object `record` belongs to, keyed by field * name — each column's declared `type` and `multiple`, the slice the spec's * cross-field classification reads. * * Supplied, every `{ $field }` comparison between two columns named here is * judged by that classification before any record is read, and one the * platform defines no answer for is refused ({@link findCrossFieldClassRefusal}). * Omitted, the evaluator judges values only, as it always has: it has no * schema of its own, and a caller without one (an aggregated row, a probe * record) is not asked for one. * * A column named here also selects the question `$contains` / `$notContains` * ask of it: MEMBERSHIP on a JSON-stored column, SUBSTRING on any other. A * column it does not name is judged by its stored value instead * ({@link containsAsksMembership}). */ readonly fields?: Readonly>; } /** True iff `record` satisfies `filter`. A null/empty filter matches everything. */ declare function matchesFilterCondition(record: Record, filter: FilterCondition | null | undefined, options?: MatchesFilterOptions): boolean; /** * [#20355] One `{ $field }` comparison between two declared columns that share * no comparison class, as {@link findCrossFieldClassRefusal} found it. */ interface CrossFieldClassRefusal { /** The constrained column — the key the comparison sits under. */ readonly field: string; /** The comparison operator (`$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`). */ readonly operator: string; /** The referenced column — the `{ $field }` value. */ readonly reference: string; /** The classification's answer: two classes, or a column with none. */ readonly verdict: Extract; /** * The comparison and both declarations in words. SERVER-SIDE ONLY: it names * the columns of a predicate the caller may not have written, so it goes to * a log, never into an error message (see {@link crossFieldClassError}). */ readonly diagnostic: string; } /** * [#20355] The first `{ $field }` comparison in `filter` whose two columns are * both declared in `fields` and share no comparison class — `null` when there * is none. Pure: it reads the filter and the declarations, never a record. * * ## The rule, and why it is the spec's * * A column-to-column comparison has one meaning only between two columns of * ONE comparison class, and a file field, a formula field or a column holding * a list or an object has no class at all (`@objectstack/spec/data` * `crossFieldComparisonVerdict`, lifted from driver-sql's #5222 boundary by * #20347). Across classes the backends answer differently: SQLite orders * every TEXT above every INTEGER, while this evaluator's JS comparison coerces * (`'open' > 5` is false), so a comparison that is well defined nowhere gets a * different answer on each path. * * Measured through the real plugin-security and ObjectQL on driver-sql, on * SQLite and on PostgreSQL, before this rule: an RLS policy * `record.status != record.amount` (text vs number), `record.status != * record.photo` (text vs image), `record.status != record.is_open` (text vs a * formula field) or `record.status != record.meta` (text vs json) answered the * read it scopes with `INVALID_FILTER` / 400, because driver-sql refuses to * compile the comparison; and the insert its `check` judges — or its `using`, * standing in as the check — was ADMITTED and stored, because this evaluator * compared the two raw values (`'open' !== 5`). One policy, two answers, the * permissive one on the write side. The write check now refuses the * comparison exactly where the read does, by the same classification. * * ## What it judges, and what it leaves * * The six operators a `{ $field }` comparand is declared for * ({@link ARRAY_REFUSED_OPERATORS}: the ones that compare ONE value), at any * depth under `$and` / `$or` / `$not`, whether or not the reference carries an * `addDays` offset — driver-sql asks the class question before it reads the * offset. Only a comparison whose BOTH columns are keys of `fields` is judged: * a dotted path, or a column the object does not declare, is a different * question with its own answer elsewhere (the RLS compiler's field guard * refuses an undeclared column before a filter ever reaches this evaluator). * A declared type outside `FieldType` is not judged either * (`unjudged`) — it is not a declaration the classification covers, and the * metadata schema refuses it at authoring. */ declare function findCrossFieldClassRefusal(filter: FilterCondition | Record | null | undefined, fields: Readonly>): CrossFieldClassRefusal | null; /** * [#20355] The comparison-class refusal an error carries, or `null` for any * other error — the read half of {@link crossFieldClassError}, for a caller that * logs the refused comparison server-side (the RLS write gate names the policy * beside it). */ declare function crossFieldClassRefusalCarriedBy(err: unknown): CrossFieldClassRefusal | null; /** 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; export { CEL_PUSHDOWN_LIMITS_MODE, CEL_STDLIB_FUNCTIONS, type CelAstNode, type CelBoundsOverrun, type CelFieldRole, type CelFilterCompileOptions, type CelFilterCompileResult, type CelFilterFailReason, type CelLimitKey, type CelParseResult, type CelPushdownLimitsMode, type CelRootIdentifiersResult, type CelRootsRefusalCode, type CrossFieldClassRefusal, DEFAULT_LIMITS, DEFAULT_TRAVERSAL_ROOT, type DialectEngine, EXPRESSION_REFUSAL_CODES, type EvalContext, type EvalError, type EvalPermissions, type EvalResult, type ExprInput, type ExprSchemaHint, type ExprValidationCode, type ExprValidationError, type ExprValidationResult, ExpressionEngine, type ExpressionRefusal, type ExpressionRefusalCode, type ExpressionRefusalParams, type ExpressionSourceKind, type FieldRole, type InferredValueType, type MatchesFilterOptions, type ParseCelToAstOptions, type PermissionBinding, type RelationshipTraversalAnalysis, SCOPE_ROOTS, type SeedPrimitive, type SeedValue, TEMPLATE_FORMATTERS, type TraversalConflict, type TraversalConflictKind, type UnknownFunctionCall, __resetPushdownLimitWarnings, analyzeRelationshipTraversals, buildScope, celEngine, celPushdownLimitsMode, collectCelRootIdentifiers, compileCelToFilter, cronEngine, crossFieldClassRefusalCarriedBy, expectedDialect, findCrossFieldClassRefusal, findTraversalConflicts, firstUndeclaredReference, firstUnknownFunctionCall, formatValue, getEngine, hasDialect, inferExpressionType, introspectScope, isPushdownableCel, isSupportedRlsExpression, lowerCelAst, matchesFilterCondition, nearestName, normalizeExpression, normalizeExpressionTree, parseCelToAst, parseCelToAstWithReason, printCelAst, register, registerPermissionPredicate, registerStdLib, resolveSeed, resolveSeedRecord, setCelPushdownLimitsModeForTests, sqlPredicateToCel, templateEngine, toEvalPermissions, validateExpression };