Pin CEL_STDLIB_FUNCTIONS to the real CEL Environment as a declared subset - #13931
Conversation
…#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
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
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
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.registerFunctionsites are the ground truth and yield the 72Environmentagainst one that has runregisterStdLibshows 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.Environment.getDefinitions())lib/index.d.tson the exportedEnvironment: "Return user-facing definitions for all registered variables and functions, including the built-ins inherited from the global environment." Returnsvariablesplusfunctions, each carryingname,signatureandreceiverType. This is the seam the pin uses.The 72, decomposed — the number that was missing
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 onlytypeis bare-callable.Consumer census (A2.4), with a firing positive control
This repo — every reference to the exported constant:
packages/formula/src/validate.tsintrospectScope().functions— the authoring catalog handed to authors incl. AIpackages/mcp/src/mcp-http-tools.tsvalidate_expressionMCP tool returnsinScope.functionsverbatim to the modelpackages/formula/src/cel-engine.test.tspackages/formula/src/skill-catalog-sync.test.tsskills/objectstack-formula/SKILL.mdas a call form@objectstack/lintdoes not read it — grepping the lint package's sources for the symbol returns nothing, so the #13594 ruling (oracle pinned to the engine's owncheck()) still holds on today'smain. 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:
from '@objectstack/formula'in objectui TS sources — 4 files (core/src/evaluator/fieldRules.ts,optionLint.ts,rowPredicateCanon.ts, and a test).introspectScopein 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.tslazily loadsintrospectScopeand keepsres.functions;buildCandidatespushes each name as a suggestion of kindfunction;CelPredicateField.tsxinserts 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 autocompletesplit(andmap(into an author's expression, which faultsno 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:
cel-stdlib-drift.test.ts— the drift pin, reading the authoritative env throughbuildEnv(the same constructorcelEngine.evaluateuses) andgetDefinitions():registerStdLibadds is advertised — measured by diffing the same environment shape before and after our registrations, never transcribed from the sites;bytes dyn type uint), each with its reason, so a cel-js upgrade cannot add a bare-callable built-in unnoticed;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.cel-engine.ts— extracts the environment options intoCEL_ENV_OPTIONSand exports it andbuildEnv, package-internal (not re-exported fromindex.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.packages/spec/src/.CEL_STDLIB_FUNCTIONSliteral fromorigin/mainand from HEAD and diffing gives no output; the two extracts hash to one distinct sha256."."todist/index.d.ts. In the rebuiltdist/index.d.ts,buildEnvandCEL_ENV_OPTIONSscore 0 hits whileCEL_STDLIB_FUNCTIONSscores 2 — a firing positive control for that zero.index.tsis not in the diff.needs:contract-reviewis 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-objectblob, and restore undertrap ... EXIT INT TERMwith absolute paths.No dist rebuild is owed here, and that is a measured property rather than an omission: the pin imports
./cel-engine,./stdliband./validateas relative in-package source paths, so vitest compiles the mutated source directly. Noexports-resolveddist/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 (
splitadded to the catalog).Mutation proof: injected-text count 1, removed-text count 0, blob
a4c4d861becomes95be556b.Result — 4 red across 3 files:
['split']['split']cel-engine.test.ts: "no probe call defined for advertised functionsplit"skill-catalog-sync.test.ts: not documented in the skillAblation 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:
['median']bytes dyn median type uint, expectedbytes dyn type uintcel-engine.test.ts— green,skill-catalog-sync.test.ts— green: 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 HEADempty, and both files' worktree blobs equal to their HEAD blobs (a4c4d861...,ab4a2280...), with zerosplit/medianresidue.Verification — all at
4abcfa10bd(final commit, after mergingorigin/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, soorigin/mainwas 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 test— 26 files, 666 tests, all passedpnpm --filter @objectstack/formula typecheck— exit 0check: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-bytesNOT MEASURED — recorded as such, not as passes:
check-test-completeness— exit 3 = PREREQUISITE NOT METcheck:dual-build-cjs-loads— exit 3 family, not runcheck:type-check-debt— needs the whole built workspace closure; left to CIpnpm --filter @objectstack/formula typechecksays nothing about the new test file.packages/formula/tsconfig.jsoncarriesexclude: ["**/*.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 incel-stdlib-drift.test.ts. The 17 errors that program reports are pre-existing, in five other test files, and are the already-ledgered conditioncheck:type-check-coveragereports 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 lintwas 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:
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.getDefinitions(). Cheap, but it hands an AI authordyn(x)anduint(x), which mostly widen the ways to emit something unusable.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