Skip to content

Pin CEL_STDLIB_FUNCTIONS to the real CEL Environment as a declared subset - #13931

Merged
zhuangjianguo merged 2 commits into
mainfrom
claude/issue-13831-cel-stdlib-census
Aug 31, 2026
Merged

Pin CEL_STDLIB_FUNCTIONS to the real CEL Environment as a declared subset#13931
zhuangjianguo merged 2 commits into
mainfrom
claude/issue-13831-cel-stdlib-census

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Fixes #13831

The catalog's contents are unchanged. What lands is the drift pin the card names as owed by either route, plus a docblock that records what the 35/72 gap actually is. The route decision itself is escalated below, because the measurement changed the shape of both routes.

Zone-2 verdicts

# Assumption Verdict What decided it
A2.1 The 35/72 counts are still current HELD Both re-measured on today's main. The literal carries 35 entries (validate.ts, found by symbol — it had moved to line 585). Environment.getDefinitions() on the real evaluation env yields 72 distinct function names.
A2.2 The 27 registerFunction sites are the ground truth and yield the 72 FALSIFIED The 27 sites yield exactly 27 names. Diffing a bare Environment against one that has run registerStdLib shows 50 of the 72 come from cel-js's own global registrations. Deriving the constant "from the registration site" cannot reach 72 and is impossible as stated.
A2.3 An enumeration API exists (Environment.getDefinitions()) HELD Public and declared in cel-js 8.0.0's lib/index.d.ts on the exported Environment: "Return user-facing definitions for all registered variables and functions, including the built-ins inherited from the global environment." Returns variables plus functions, each carrying name, signature and receiverType. This is the seam the pin uses.
A2.4 Consumer census, incl. objectui CONFIRMED, and it decided the card Full census below. One external consumer, and it settles the membership rule.

The 72, decomposed — the number that was missing

72 registered names
 = 39 callable BARE, as fn(x)
 + 33 callable ONLY on a receiver, as x.fn()

39 bare-callable
 = 27 added by registerStdLib   -> all 27 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

Every one of the 35 advertised names is registered and bare-callable — nothing in the catalog is stale or faulting. The 37-name gap is 33 receiver-only names plus 4 withheld bare-callables.

The card's example gap list lands almost entirely in the receiver-only bucket: of type map filter exists split substring getFullYear base64 json orValue, nine are receiver-only and only type is bare-callable.

Consumer census (A2.4), with a firing positive control

This repo — every reference to the exported constant:

Consumer What it does with it
packages/formula/src/validate.ts introspectScope().functions — the authoring catalog handed to authors incl. AI
packages/mcp/src/mcp-http-tools.ts the validate_expression MCP tool returns inScope.functions verbatim to the model
packages/formula/src/cel-engine.test.ts pin: every entry must evaluate — via a bare-call probe expression per entry
packages/formula/src/skill-catalog-sync.test.ts pin: every entry must appear in skills/objectstack-formula/SKILL.md as a call form

@objectstack/lint does not read it — grepping the lint package's sources for the symbol returns nothing, so the #13594 ruling (oracle pinned to the engine's own check()) still holds on today's main. This PR does not touch that gate.

objectui — the symbol itself: zero hits.

That zero is a reading because the channel demonstrably works on the same query shape in the same tree:

  • positive control 1: from '@objectstack/formula' in objectui TS sources — 4 files (core/src/evaluator/fieldRules.ts, optionLint.ts, rowPredicateCanon.ts, and a test).
  • positive control 2: introspectScope in objectui TS sources — 28 hits.

So objectui consumes the constant transitively, through introspectScope, and that path is the load-bearing one:

  • app-shell/src/views/metadata-admin/celAuthoring.ts lazily loads introspectScope and keeps res.functions;
  • buildCandidates pushes each name as a suggestion of kind function;
  • CelPredicateField.tsx inserts a chosen suggestion as `label(` — a bare call, appended verbatim into the author's predicate.

Why route 1 is not merely impossible-as-stated — it is the wrong direction

Route 1 via the registration site is impossible (A2.2). Route 1 via getDefinitions() is mechanically possible, but flattening all 72 names into this list would make the Studio predicate editor autocomplete split( and map( into an author's expression, which faults no matching overload. The pre-existing runtime pin cannot express those entries either — it needs a bare-call probe per name.

So the catalog's real membership rule, never previously written down, is bare-callability, and it is what makes the gap deliberate rather than stale.

What this PR changes, and what it deliberately does not

Does:

  1. cel-stdlib-drift.test.ts — the drift pin, reading the authoritative env through buildEnv (the same constructor celEngine.evaluate uses) and getDefinitions():
    • A. every advertised name is registered and bare-callable (subset direction);
    • B. every bare-callable name registerStdLib adds is advertised — measured by diffing the same environment shape before and after our registrations, never transcribed from the sites;
    • C. the withheld bare-callables are exactly a declared ledger (bytes dyn type uint), each with its reason, so a cel-js upgrade cannot add a bare-callable built-in unnoticed;
    • D. the receiver-only set is non-empty and disjoint from the catalog — pinning the claim that the catalog can never be "all registered names".
  2. validate.ts — the docblock now records the measured decomposition, the bare-callability membership rule with its consumer evidence, and that the constant is not an oracle for rejecting unknown functions.
  3. cel-engine.ts — extracts the environment options into CEL_ENV_OPTIONS and exports it and buildEnv, package-internal (not re-exported from index.ts), so the pin measures the environment the engine really builds rather than a hand-rebuilt lookalike.

Does not: change the constant's contents, rename the symbol, or touch skills/, the lint gate, or any consumer. No behaviour changes anywhere.

Contract review self-declaration — both limbs NO

Determined from the actual diff, which is 4 files: the changeset, cel-engine.ts, cel-stdlib-drift.test.ts, validate.ts.

  • Path limb — NO. Zero files under packages/spec/src/.
  • Content limb — NO, on three measurements:
    1. The exported value is byte-identical. Extracting the CEL_STDLIB_FUNCTIONS literal from origin/main and from HEAD and diffing gives no output; the two extracts hash to one distinct sha256.
    2. No new published symbol. The package declares exactly one export condition, "." to dist/index.d.ts. In the rebuilt dist/index.d.ts, buildEnv and CEL_ENV_OPTIONS score 0 hits while CEL_STDLIB_FUNCTIONS scores 2 — a firing positive control for that zero. index.ts is not in the diff.
    3. Nothing accepts or rejects differently: no schema, validator or gate is touched, and the only runtime edit is an options literal becoming a named const passed to the same constructor call.

needs:contract-review is therefore not attached, per the 2026-08-28 maintainer ruling that an open carrier must always mean genuinely pending review.

Ablation — the pin is proven able to fail, on the committed implementation

Both ablations mutate committed source, prove the mutation on disk by anchored greps in both directions and a changed git hash-object blob, and restore under trap ... EXIT INT TERM with absolute paths.

No dist rebuild is owed here, and that is a measured property rather than an omission: the pin imports ./cel-engine, ./stdlib and ./validate as relative in-package source paths, so vitest compiles the mutated source directly. No exports-resolved dist/ sits between the mutation and the assertion, which is the configuration in which a stale build can fake a green ablation.

Ablation 1 — advertise a receiver-only registered name (split added to the catalog).
Mutation proof: injected-text count 1, removed-text count 0, blob a4c4d861 becomes 95be556b.
Result — 4 red across 3 files:

  • drift A: "advertised as a bare-callable function but registered ONLY as a receiver method", ['split']
  • drift D: "receiver-only names leaked into the catalog", ['split']
  • cel-engine.test.ts: "no probe call defined for advertised function split"
  • skill-catalog-sync.test.ts: not documented in the skill

Ablation 2 — the isolating one: register a new bare-callable stdlib function (median) and do not advertise it.
This is the census's own failure mode: the stdlib grows, the catalog does not.
Result — 2 red, both in the new pin, and the two pre-existing pins stayed GREEN:

  • drift B: "registered by registerStdLib but NOT advertised", ['median']
  • drift C: withheld set became bytes dyn median type uint, expected bytes dyn type uint
  • cel-engine.test.tsgreen, skill-catalog-sync.test.tsgreen: both iterate only the advertised names, so neither can see a name that was never advertised.

That is the targeting claim: ablation 2 is red only in this file, which is what shows the pin closes a hole no existing test covers. Ablation 1 is red in the older pins too, but they catch the shape error incidentally (a missing probe, a missing doc row) while A and D name it.

Restore proven after each: git diff HEAD empty, and both files' worktree blobs equal to their HEAD blobs (a4c4d861..., ab4a2280...), with zero split / median residue.

Verification — all at 4abcfa10bd (final commit, after merging origin/main)

Gate family derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (the script reads the change set itself; the first run warned STALE TREE, so origin/main was merged and it was re-derived). Exit codes captured before any pipe.

  • pnpm --filter @objectstack/formula build — green (check-dts-emitted: 1/1 declared declaration file(s) present)
  • pnpm --filter @objectstack/formula test26 files, 666 tests, all passed
  • pnpm --filter @objectstack/formula typecheck — exit 0
  • 30 derived gate families green, including check:engine-double-contract, check:where-matcher, check:cross-package-test-inputs, check:query-options-erasure, check:type-check-coverage, check:test-source-alias, check:type-source-resolution, check:published-files, check:undeclared-dep-imports, check:doc-authoring, check:nul-bytes

NOT MEASURED — recorded as such, not as passes:

  • check-test-completeness — exit 3 = PREREQUISITE NOT MET
  • check:dual-build-cjs-loads — exit 3 family, not run
  • check:type-check-debt — needs the whole built workspace closure; left to CI
  • pnpm --filter @objectstack/formula typecheck says nothing about the new test file. packages/formula/tsconfig.json carries exclude: ["**/*.test.ts"], so the green above covers no test file at all. Measured separately with an explicit tsc program that includes them, confirmed by --listFiles (the new file is in the program): 0 errors in cel-stdlib-drift.test.ts. The 17 errors that program reports are pre-existing, in five other test files, and are the already-ledgered condition check:type-check-coverage reports as "17 package(s) still hide their own tests from tsc" (tracked, [P2] framework: 66 个包用 tsup 构建、无人做类型检查 —— 实测 18 个包共 380 处 code-tier 错误(#4118 的 framework 侧对应) #4311) — untouched by this PR.

Repo-wide pnpm lint was left to CI, which runs the farm exactly once regardless.

Open question for the maintainer — the route itself

This PR deliberately stops short of the route decision, because the measurement reshaped it. The fork is no longer "complete registry vs. advertised subset" but:

  1. Leave the catalog at 35 and keep it explicitly a subset — what this PR's docblock and pin already assume. The four withheld names (bytes dyn type uint) are CEL type primitives with no measured demand; withholding is the conservative direction for AI authoring, since under-advertising costs expressiveness while over-advertising emits faulting code.
  2. Widen to the 39 bare-callables — a 4-name addition, derivable from getDefinitions(). Cheap, but it hands an AI author dyn(x) and uint(x), which mostly widen the ways to emit something unusable.
  3. Rename the symbol so its name stops promising completeness. Not improvised here: it is a published-surface break on an exported symbol.

Routes 2 and 3 each carry a second half in skills/objectstack-formula/SKILL.md, which today says the constant "is the canonical list" and that "Only the functions above are callable" — the second is measurably false in the safe direction (type(x) resolves and evaluates; so do 33 receiver methods). Correcting it is the same decision, so it is left with the fork rather than pre-empted, and this PR touches no published skill.

Generated by Claude Code


Generated by Claude Code

claude added 2 commits August 31, 2026 16:44
…#13831)

The catalog advertises 35 names while the evaluation Environment resolves 72,
and no assertion related the two. Measure the gap and pin it.

Measured decomposition (re-measured on every test run, not transcribed):

  72 registered = 39 bare-callable + 33 receiver-only
  39 bare-callable = 27 from registerStdLib (all advertised)
                   +  8 cel-js built-ins (advertised)
                   +  4 cel-js built-ins withheld (bytes, dyn, type, uint)

The 37-name gap is not staleness. A catalog entry is spent as a bare call --
objectui's Studio predicate editor inserts a suggestion as `name(` verbatim,
and the runtime guard probes each entry with a bare-call expression -- so the
33 receiver-only names cannot be flattened into it: doing so would
autocomplete `split(` into an author's predicate and fault.

Adds cel-stdlib-drift.test.ts, which reads the authoritative environment via
Environment.getDefinitions() and asserts subset-ness, completeness over our own
registrations, and a declared withholding ledger. Exports CEL_ENV_OPTIONS and
buildEnv from cel-engine.ts (package-internal; not re-exported from index.ts)
so the pin measures the same environment celEngine.evaluate builds.

Catalog contents unchanged; no consumer behaviour changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/formula/src/validate.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/formula/src/validate.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 55519d50363147da03df0af068fb82fcc6d1d8c1packageMentionDocs.

Which tree this was computed on

This run read content/docs from cde979109b8a7c9dd494e714dafd15387e6f7c0d — the merge of head 4abcfa10bd5f72b3be8a45e9b4f542906108bdb7 into base 55519d50363147da03df0af068fb82fcc6d1d8c1, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cde979109b8a7c9dd494e714dafd15387e6f7c0d && git checkout cde979109b8a7c9dd494e714dafd15387e6f7c0d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 55519d50363147da03df0af068fb82fcc6d1d8c1 4abcfa10bd5f72b3be8a45e9b4f542906108bdb7 && git checkout -B drift-repro 55519d50363147da03df0af068fb82fcc6d1d8c1 && git merge --no-ff 4abcfa10bd5f72b3be8a45e9b4f542906108bdb7

node scripts/docs-audit/affected-docs.mjs --json 55519d50363147da03df0af068fb82fcc6d1d8c1

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 31, 2026
@zhuangjianguo
zhuangjianguo marked this pull request as ready for review August 31, 2026 17:45
@zhuangjianguo
zhuangjianguo added this pull request to the merge queue Aug 31, 2026
Merged via the queue into main with commit 77d4b3c Aug 31, 2026
34 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-13831-cel-stdlib-census branch August 31, 2026 18:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants