fix(formula): converge the CEL pushdown parser onto the canonical front end, with an rc grace window (#6132) - #6766
Merged
Conversation
…nt end, with an rc grace window (#6132) `packages/formula/src/cel-to-filter.ts` — the one canonical CEL → FilterCondition pushdown compiler (ADR-0058 D1/D2/D6) — kept a private, limitless `new Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true })` with no `limits`, no stdlib and no `rewriteNullableTernary`, and read `.ast` off it. That made the RLS / sharing pushdown path the one place on the platform answering a different question from `celEngine.compile()` about what parses: an 80-term conjunction, a 40-level nest and a 200-element `$in` all reached REAL pushdown SQL while the interpreter refused each outright, and `isSupportedRlsExpression` was a thin wrapper over the same env rather than an independent gate. It now parses through `parseCelToAstWithReason`, the canonical entry (#4812), carrying DEFAULT_LIMITS. Within the limits this is behaviour-preserving, measured rather than asserted: across the 710 sources of the pushdown corpus both front ends accept, the only AST difference is `rewriteNullableTernary`'s `dyn(...)` wrap on the null-guard ternaries, and a ternary faults on its own `?:` node before the lowerer descends into a branch — so reason AND detail are byte-identical. Pinned in cel-to-filter-parse-convergence.test.ts, which rebuilds the old env to compare. Over the limits, the maintainer's A' ruling (2026-08-08, on the issue) is implemented as a single dated switch, `CEL_PUSHDOWN_LIMITS_MODE` in the new `cel-pushdown-limits.ts`: - `rc-grace` (shipping default, 17.0.0-rc.x): an over-limit predicate still compiles, off the unbounded AST the canonical entry hands back for exactly this purpose, and WARNs once per predicate naming the exceeded bound, the platform's value for it, the predicate's own measure, and the GA consequence. - `fail-closed` (v17 GA, one line): the predicate is refused as `{ reason: 'parse-error', detail: 'Exceeded maxAstNodes (256)' }`, which the RLS path already routes to RLS_DENY_FILTER. Both positions run in CI today — in `@objectstack/formula` and in `@objectstack/plugin-security`, which owns the deny sentinel — so the GA half is proven before it ships. Two assertions go red on the flip so it cannot be silent. Sister entrance: `parseCelToAstWithReason` separates "not valid CEL" from "valid CEL, over budget" and names WHICH bound was blown, its platform value, and what the source measures (cel-js's own accounting — the smallest bound it parses under, found by probing the parser, because `maxDepth` counts parenthesised recursion that leaves no AST node behind). Graded by the same by-class/by-code classifier `compile`/`evaluate` use (#6223), never by error prose; the parity is pinned. `parseCelToAst` is unchanged and still collapses every refusal to `null`. `@objectstack/lint` needs no change at either position: its two enforceability gates read `isSupportedRlsExpression` / `compileCelToFilter` and both suites pin "the lint verdict IS the consumer's verdict" in both directions, so authoring reporting flips with the runtime by construction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bx3H8DJhBsmgDoMp8Tz87T
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…lip docblock (#6132) Two follow-ups from running the gate list: - `check:type-check-debt` went red: `@objectstack/formula`'s TEST_DEBT ledger records 17 raw tsc errors and the new suite made it 18. The added error was `TS2584: Cannot find name 'console'` — this package compiles with neither the DOM lib nor `@types/node`, which is why the compiler itself reaches the sink through `globalThis`. The test now spies on the same object the compiler writes to, rather than a differently-obtained one, which is also the only version of this spy that cannot go green over a silent sink. Back to 17. - The switch docblock named three test files that do not exist (the suites were consolidated into one). It now names the real file and states the flip's measured blast radius: 10 tests, all in that file, and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bx3H8DJhBsmgDoMp8Tz87T
The grace WARN's "measurement capped" branch spelled the cap factor as a literal `64` while `measureOverrun` read it from `MEASURE_CAP_FACTOR` — two copies of one number, in two files, where a drift would make the WARN quote a bound the measurement never used. The constant is now exported from `cel-engine.ts` (not from the package index: it is an implementation detail of the diagnostic, not public surface) and read in both places. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bx3H8DJhBsmgDoMp8Tz87T
…uessed key (#6132) `parseCelToAstWithReason`'s "cel-js raised a limit fault whose key this package cannot read" branch filled the overrun with `maxAstNodes` while its own comment said a guessed name is the thing to avoid. It is unreachable on cel-js 8.0.0 — `Parser#limitExceeded` always phrases it `Exceeded <key> (<n>)` — but a guessed key is worse than none: the author goes and shortens the wrong axis. The branch now reports `{ limit: null, limitValue: null }`, which the types carry, and hands back no unbounded AST, so the pushdown path fails closed on it in either position of the switch rather than compiling something it cannot describe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bx3H8DJhBsmgDoMp8Tz87T
os-zhuang
marked this pull request as ready for review
August 8, 2026 17:14
This was referenced Aug 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6132
Implements the maintainer's A′ ruling of 2026-08-08, recorded verbatim on the issue:
Premises, re-measured on current
main(a5d2573)The issue's facts were measured on
bc67c28e2. Re-verified before writing code; every one holds, including after PR #6677's same-day rewrite ofcel-engine.ts.parseCelToAstcelEngine.compile()isPushdownableCel(before)bounds: Exceeded maxAstNodes (256)unsupported(arithmetic)bounds: Exceeded maxAstNodes (256)bounds: Exceeded maxDepth (32)bounds: Exceeded maxDepth (32)$inbounds: Exceeded maxListElements (64)$in(AT the bound)$inbounds: Exceeded maxListElements (64)What changed
packages/formula/src/cel-engine.ts— the sister entrance (ruling §3).parseCelToAstWithReason(source, opts?)returns{ ok: true, ast }or a discriminated refusal:'empty'/'parse'(not valid CEL) /'bounds'(valid CEL, over budget) carrying{ limit, limitValue, measured, summary }.parseCelToAstis nowparseCelToAstWithReason(...)collapsed tonull— its #4812 contract is untouched, and that is pinned.The verdict is graded by the same
classifyCelFaultcompile/evaluateuse — error class plus structuredcode, never prose. PR #6677's fresh by-code table is not touched;cel-parse-reason.test.tspins that a bounds overrun isboundson both entrances with the same summary, that a syntax fault isparseon both, and that an author-controlled field name spellingExceeded_maxAstNodesis still gradedparse(the #6223 hazard).The limit key comes from
ParseError#summary(Exceeded maxAstNodes (256)— built byParser#limitExceededfrom a fixed key set), never#message(which isformatErrorWithHighlight's rendering of the author's own source line). The captured key is validated againstDEFAULT_LIMITS, so a cel-js that rephrases the sentence degrades to "bounds, limit unknown" instead of inventing a limit name.The measure is cel-js's own accounting, not a second implementation of it: the smallest
limits[key]under which the source parses, every other bound lifted, found by exponential probe + binary search (O(log n)parses, capped at 64× the bound). A node-walk would report3for a 60-deep parenthesis nest, becausemaxDepthcounts parenthesised recursion that leaves no AST node behind. Measuring happens only on the grace path, where an unbounded parse has already occurred — a bounds refusal never re-parses a source it has just declared too big.packages/formula/src/cel-pushdown-limits.ts(new) — the dated switch (ruling §2).CEL_PUSHDOWN_LIMITS_MODE: 'rc-grace' | 'fail-closed', shipping'rc-grace'. Intended flip point: the v17.0.0 GA release — whenpackages/formula/package.jsonleaves17.0.0-rc.x. Flipping is exactly one line; the module docblock says so, names the two assertions that go red on it, and states that nothing else needs to move.packages/formula/src/cel-to-filter.ts— the convergence (ruling §1).getParseEnvis gone. Both entry points (compileCelToFilter,isPushdownableCel) go throughparseForPushdown, which asks the canonical entry and then, on a bounds refusal, either WARNs and compiles off the unbounded AST (rc-grace) or fails closed (fail-closed).The GA refusal is
{ ok: false, reason: 'parse-error', detail: 'Exceeded maxAstNodes (256)' }— deliberately the existing reason, not a fourth one:parse-erroris what every consumer already routes to its deny path, and a new reason value would be a new branch none of them has. Which bound was blown rides indetail.The WARN is emitted once per source (bounded memo), through
globalThis.consolerather than the bareconsoleglobal — this package builds with neither the DOM lib nor@types/node, and a bare embedder must degrade to silence, not to aReferenceErrorthrown from inside a security compiler.A1 — within-limits parity: measured, and it holds
Corpus: 1604 candidate strings harvested from every suite that drives
compileCelToFilter/isPushdownableCel(formula, lint × 2, plugin-security, plugin-sharing, service-analytics, the dogfood conformance ledger and authz matrix), plus the SQL-bridged form of each. 710 sources parse under both front ends. AST differences: 3 — all of themrewriteNullableTernary'sdyn(…)wrap on a null-guard ternary. Zero verdict changes.The issue flagged this as scope to pin rather than discover: "a ternary was already non-pushdownable, but the REASON it is non-pushdownable changing is behavior". Measured across seven ternary shapes (top-level, as a comparison operand, as an
incontainer, as a receiver of a comparison, as a string-method argument), reason and detail are byte-identical before and after — the?:node faults inlowerCondition/classifybefore the lowerer ever descends into a branch, so the wrap the rewrite adds inside a branch is never reached:cel-to-filter-parse-convergence.test.tsrebuilds the pre-#6132 environment — the one place in the repo that still may — and demands an equalCelFilterCompileResultfor all 47 corpus shapes, plus an explicit assertion that the AST really does differ for a ternary (so the pin cannot go vacuous).No corpus source was newly refused: the corpus contains no over-limit predicate.
A2 — the authoring-time lint face
Confirmed, and no
packages/lintedit is needed at either switch position.parseCelToAstin packages/lint 绕过 @objectstack/formula 直接 parse CEL —— 两个解析入口对「什么能解析」会给出不同答案 #4812/refactor(formula,lint): parseCelToAst 成为唯一的 CEL 解析入口 (#4812) #6130. Measured: a 60-level-nestedvisibleWhenyieldsvisibility-predicate-syntax(error), messagevisibility predicate is not valid CEL — Exceeded maxDepth (32).conditionis already an authoring error today, under the shippingrc-gracedefault:expression-invalid, quotingExceeded maxAstNodes (256).validateSharingRuleEnforceabilitycorrectly stays silent — it defersparse-errorto the rule that owns syntax, and that rule reports it.validateRlsPredicateEnforceability→isSupportedRlsExpression,validateSharingRuleEnforceability→compileCelToFilter) are downstream of this switch, and both suites pin "the lint verdict IS the consumer's verdict" in both directions. Authoring reporting therefore flips with the runtime by construction and cannot drift from it. Measured: flipping the switch tofail-closedturns an 80-term RLSusingfrom clean intorls-predicate-unparseable(error), with no lint change.One thing worth a follow-up, not touched here (cross-lane surface, and out of this card's scope): at GA an over-limit RLS predicate is reported under the id
rls-predicate-unparseablewith prose about SQL-vs-CEL syntax, which is off-label for a bounds overrun even though the message does carry the exactExceeded maxAstNodes (256)detail. Arls-predicate-over-budgetid with its own hint would read better. Flagged, not done.A3 — per-consumer declarations
plugin-securityRLSCompiler.compileExpressioncompileCelToFilter(cel, { variables: { current_user } })!ok→null→RLS_DENY_FILTERwhen it is the only applicable policy. No source change; new test pins both switch positions end-to-end.plugin-securityRLSCompiler.compileFilter!isSupportedRlsExpression(predicate)(the ADR-0056 D4 drop-warning)fail-closedthe drop emitsDROPPED (no enforcement).formulaisSupportedRlsExpressionisPushdownableCel(sqlPredicateToCel(x))rc-grace: unchanged.plugin-sharingbootstrapDeclaredSharingRulescompileCelToFilter(cel, { variables: {} })!ok→ rule skipped + boot WARN. At GA an over-limit condition stops being seeded — and it is already an authoring error today (A2), so it cannot reach boot from a stack that passedos build.service-analyticsread-scope-sqlFilterConditiontheRLSCompileremitslintvalidateRlsPredicateEnforceabilityisSupportedRlsExpression/isPushdownableCelrc-grace,rls-predicate-unparseableatfail-closed.lintvalidateSharingRuleEnforceabilitycompileCelToFilterparse-errorto the syntax rule, which already reports over-limit conditions today.lintvalidateVisibilityPredicates,validateNullGuardsparseCelToAstTests
Rejection- and WARN-class tests assert specific identity; there is no bare
toThrow()anywhere in the diff.cel-parse-reason.test.ts(24) — the sister entrance: which limit, its platform value, the exactsummary, the measure (200for the 200-element list, exactly), no measurement/no unbounded parse unless asked;parseCelToAst'snullcontract; both-entrance agreement withcelEngine.compile; the by-code hazard.cel-to-filter-parse-convergence.test.ts(56) — A1, both directions, against the rebuilt old env.cel-to-filter-limits.test.ts(27) — the switch in both positions on the three measured shapes:rc-gracecompiles + WARNs (asserting the limit name,limit <N>,Exceeded <limit> (<N>), a numeric measure strictly over the bound, the GA consequence, once-per-source dedupe, and that the compiled filter is real);fail-closedrefuses with the exact{ reason, detail }and does not warn; a genuine syntax fault keeps its own detail in both. Boundary shapes at the bound are admitted in both positions. The shipped default is pinned torc-grace.plugin-security/rls-pushdown-limits.test.ts(20) — the same three shapes on the RLS path:rc-grace→ real filter, not the sentinel, no drop-warning, console WARN naming the limit;fail-closed→RLS_DENY_FILTER, with an observableDROPPED (no enforcement)log, no grace WARN, and the multi-policy blast radius stated.No new fake engine was introduced, so the
assertEngineDeleteDispatchrule does not apply to this diff.Reverse verification — direction written before running
Each pin was expected to fail if its subject were removed, and each was run in that state before being run in the passing state:
cel-to-filter.ts) → everyfail-closedassertion incel-to-filter-limits.test.tsandrls-pushdown-limits.test.tsmust fail, and the grace WARNs must stop firing. (Expected: red.)rewriteNullableTernaryhalf of A1 — i.e. assert the ternary AST is equal across front ends →cel-to-filter-parse-convergence.test.ts's "the rewrite really does change the AST" must fail, proving the reason-parity pins below it are not vacuous. (Expected: red.)CEL_PUSHDOWN_LIMITS_MODEto'fail-closed'→ exactly the two documented assertions go red (the shipped-default pin and therc-gracesuite), and nothing else — the property that makes the GA flip a one-line change with a known blast radius. (Expected: exactly those, red.)parse(drop thecode === 'limit_exceeded'arm) → the both-entrance agreement test and every "names the limit" assertion must fail. (Expected: red.)Results are reported in the final comment on #6132.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Bx3H8DJhBsmgDoMp8Tz87T
Generated by Claude Code