Skip to content

perf(formula): reuse one CEL environment per entry point instead of building the stdlib on every compile and evaluate - #24629

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24536-cel-env-reuse
Oct 19, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24536-cel-env-reuse

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #24536

Clause-②: no (an internal cache: no accept set moves, no evaluation result moves, no exported signature changes; owes the seat's review of record before the queue)

Dev session session_01QNSdgij7mWezAKUbkzSmmR, dispatched by the domain:engine seat 1 (session_01JtSQQ3EWsbNPzyYMug1tXy) under claim 6272538729.

What changes

celEngine.evaluate and celEngine.compile built a new cel-js Environment on every call and registered the whole stdlib into it (buildEnv → registerStdLib + registerNumericCoercions). objectql's applyFormulaPlan reaches evaluate once per row per formula field. Three commits change that:

  1. 79fde52c — one evaluation environment, with a per-evaluation binding slot. The stdlib's closures (now(), today(), daysFromNow(), daysAgo(), can, hasCapability) now read the clock, the timezone and the PermissionBinding through a reader (registerBoundStdLib(env, read), package-internal in stdlib.ts). cel-engine.ts builds the shared environment once with a reader pointed at an unexported module binding, activeBinding. evaluate saves it, sets its own binding and restores the saved one in a finally. A nested evaluation and a thrown one therefore both leave the outer binding exactly as it was, and no binding outlives its evaluation. can still answers about the acting subject by IDENTITY: the binding is made from the EvalUser that buildScope mints, as before.
  2. b5ec0a02 — compile reuses canonicalParseEnv (??=), the environment parseCelToAst already shares.
  3. 8eb3b5a8 — a bounded per-source program cache on the evaluation environment. Taken because the measurement below shows parsing and checking were most of the remaining cost once the environment was reused.
    • Key: the source env.parse is handed, AFTER rewriteTemporalEquality (row-dependent), rewriteNullableTernary and the overload retry's rewriteFaultedOperands. A rewrite decided by the row is part of the key.
    • Bound: 500 entries, oldest out first, the same shape as temporalRewriteCache.
    • Invalidation: none is needed. Every entry belongs to the one evaluation environment, whose registrations never change after it is built. A source that does not parse is never stored, so it is reparsed and refused every time.
    • One run at a time: a cached program is marked while it runs, and a nested evaluation of the same source parses a private program (commit 95f434429).

buildEnv keeps its signature and builds through the same constructor as the shared environment (buildEnvReading). So the stdlib drift pin and the unknown-function oracle still measure the environment that evaluates.

No public surface change. index.ts is untouched. registerStdLib, registerPermissionPredicate and registerCapabilityPredicate keep their signatures, and each still answers from the values its caller pinned: each is now its bind* twin with a reader that always returns those values. celEngine gains no member. The new names registerBoundStdLib and StdLibBinding are exported from stdlib.ts for cel-engine.ts only, and index.ts re-exports neither.

What a cel-js Environment holds, and what a cached program holds

Read in @marcbachmann/cel-js 8.0.0 (lib/evaluator.js, lib/registry.js, lib/parser.js, lib/type-checker.js, lib/operators.js, lib/macros.js).

The environment carries no per-evaluation state:

  • each evaluation builds its own RootContext, which holds the variable values and the only value-level cache, #convertCache;
  • Parser.parse resets its input and its limit counters on every call;
  • the registry's caches (Candidates match and check, type declarations, list, map and optional types, TypeDeclaration#matchesCache) are keyed on types;
  • the registry locks only when it is cloned, and nothing registers on the shared environment after it is built.

A parsed program is not free of evaluation state. cel-js keeps per-run state on the AST:

  • a comprehension (exists, all, exists_one, map, filter) keeps its iteration context on the AST (args.iterCtx, operators.js:667), and cel.bind keeps its binding context there (bindCtx, macros.js:195);
  • each run re-parents that overlay to its own scope (OverlayContext.reuse, registry.js:1423), and every non-iteration variable read inside the step goes through that parent;
  • callFn / callRecFn write the arguments' runtime types into ast.argTypes on every call (operators.js:302, :319).

In sequential use each of these is overwritten before it is read, so a cached program answers exactly as a fresh parse does. Two runs of ONE AST at once do not: a run re-entered from inside its own comprehension would read the inner run's scope. So a cached program serves one run at a time (runningPrograms), and a nested evaluation of the same source parses a private program. Runs are synchronous: this environment registers no async function, and cel-js refuses a Promise-valued scope value (Unsupported type: Promise, measured). A cached program also keeps its last evaluation's scope (the RootContext its overlays point at, and their iterValue / accuValue) reachable until the next evaluation of that source, bounded by the cache's 500 entries.

The three inputs that did vary per evaluation (now, timezone, binding) are the ones moved into the slot. Timezone: bound per evaluation, not a cache keyed on it. With the timezone in the slot, one environment serves every zone, so no per-timezone cache exists to bound.

Measurement (M)

Taken on a composition with formula fields: ObjectQL plus ObjectStackProtocolImplementation and a stub driver, 25 rows, three formula fields (joinNonEmpty, record.amount / 1000, date(record.due) less-than today()). The probe was a throwaway vitest file, not committed. It counts cel-js registerFunction('now(): …') (stdlib builds) and registerOperator('double + int') (buildEnv builds). The clock is a shared box, so read the ratios, not the absolutes.

reading origin/main c03b17d after env reuse (b5ec0a02) after program cache (8eb3b5a8)
environment builds per evaluate (20,000 evaluations of one source) 20,000 (1 each) 0 0
environment builds per read (25 rows × 3 formulas) 78 0 0
µs per celEngine.evaluate, one source × 20,000 141.7 9.1 3.2
µs per celEngine.compile, one source × 20,000 159.2 5.2 7.7 (no change on this path, box noise)
reference: bare cel-js parse+evaluate on a prebuilt env / evaluate of a parsed program 6.9 / 0.76 6.2 / 0.88 6.7 / 0.55

The served body of that read (protocol.findData, 3,625 bytes of JSON) is byte-identical before and after: compared as strings, true.

The program cache was taken on this reading. After the environment reuse, about 6 of the remaining 9 µs was parse and check, against under 1 µs of evaluation.

Pins

  • packages/formula/src/cel-env-reuse.test.ts counts cel-js Environment constructions through a vi.mock subclass, so every path is seen.
  • packages/formula/src/cel-eval-binding-isolation.test.ts:
    • An evaluation with no subject, after one with a subject, answers can and hasCapability exactly as a buildEnv environment with no binding does. Same kind, same message, including after a THROWN evaluation.
    • An evaluation nested inside another leaves the outer binding as it was. The nesting is a scope getter that runs a whole inner evaluation, and it is tested when the inner one returns, when it throws, and for the outer now() and timezone.
    • now and the timezone are each evaluation's own.
  • packages/formula/src/cel-program-reentry.test.ts (fix round 1, 95f434429): the same source, nested through a scope getter that evaluates it for another subject inside exists(…), map(…) and cel.bind(…), answers for the outer subject, equal to a fresh buildEnv parse, with the cache warmed first. Red on 1040a3a0c (3 failed / 1 passed).

Ablations, each red and then restored. Each used node scripts/ablation-replace.mjs: anchor hit ×1 → ×0, blob moved, restore blob == HEAD 5c934212c2f8 and git diff HEAD empty. The formula suite imports ./cel-engine from src (no dist on the path), so no rebuild leg applies.

ablation red
A1 — evaluation env reuse removed ((evaluationEnv ??= buildEnvReading(…)) → buildEnvReading(…)) repeated evaluations of one source build no new environment, what varies per evaluation … builds no environment either: 2 failed / 17 passed
A2 — compile env reuse removed ((canonicalParseEnv ??= buildEnv(…)) → buildEnv(…)) repeated compiles build no new environment: 1 failed / 9 passed
B — the finally restore dropped (activeBinding = outer; deleted) all three nested-evaluation pins: 3 failed / 6 passed
C — the one-run-at-a-time guard dropped (else if (runningPrograms.has(program)) → else if (false), fix round 1) the three nested same-source pins: 3 failed / 1 passed

B's first attempt passed an empty --replacement, which the tool refused (the replacement count moved 0 -> 0) and restored. It was re-run with --delete, and that run is the one cited.

Pin sweep. I grepped every test that names buildEnv, registerStdLib, registerPermissionPredicate, registerCapabilityPredicate or PermissionBinding, plus the predicates' refusal messages (ACTING SUBJECT, carries no permission data, carries no capability list):

  • formula: cel-stdlib-drift, unknown-function, validate, permission-predicate, capability-predicate;
  • objectql: engine-formula-default-permission, engine-option-permission-predicate, validation/rule-validator.option-visibility, and every engine-formula-*.

None moved. All of them ran green unedited in the full suites below, the stdlib drift pin and the unknown-function oracle included.

Verification

All runs are at HEAD 1040a3a0c (after merging origin/main 8fbbd5082) unless noted.

  • pnpm --filter @objectstack/formula typecheck (tsc --noEmit + check:test-typecheck): exit 0, before and after the merge.
  • pnpm --filter @objectstack/formula test: 50 files, 1363 tests passed, exit 0 (post-merge).
  • Fix round 1, at 95f434429: pnpm --filter @objectstack/formula test 51 files, 1367 passed; formula typecheck and build exit 0; pnpm --filter @objectstack/core test:repo 82 passed; the 55 gate commands derived for this round's files all exit 0. The other 11 of the 66 read only the changeset, which this round did not touch.
  • pnpm --filter @objectstack/objectql test (--project local): 447 files, 8414 tests passed, exit 0 (at 826ce6ae, pre-merge; the merge touched neither package).
  • pnpm --filter @objectstack/objectql test:repo: 1 file, 5 tests, exit 0.
  • pnpm --filter @objectstack/core test:repo: 6 files, 82 tests, exit 0.
  • pnpm --filter '@objectstack/objectql...' build: exit 0 (post-merge).
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derived 66 commands. All 66 ran with exit 0. check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET, no dist/); after pnpm build it was re-run and exited 0. Reconciliation: ✓ dispatch-gates --ran: 66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED (a DERIVED zero — all 66 recorded an exit code and none of them is 3).

Acceptance notes

  • collectCelRootIdentifiers (cel-engine.ts) still builds a fresh environment per call: buildEnv(() => new Date(0)).parse(…). Its runtime callers include objectql's rule-validator.ts and plugin-approvals' approval-service.ts. It sits outside this card's file surface (buildEnv, compile, evaluate) and was not measured here. canonicalParseEnv is the same reuse shape. Carrier: none.
  • The per-timezone cache the card offered as an alternative was not built: the timezone is bound per evaluation instead (above).

Generated by Claude Code

…zone/permissions per evaluation

celEngine.evaluate built a fresh Environment and registered the whole stdlib
on every call. It now reuses one, and the stdlib reads now(), the timezone and
the permission binding from a per-evaluation slot that evaluate saves, sets and
restores in a finally.

Claude-Session: https://claude.ai/code/session_01QNSdgij7mWezAKUbkzSmmR
Co-authored-by: Claude <noreply@anthropic.com>
With the environment reused, parsing and checking were most of what an
evaluation still cost. evaluate now runs a bounded per-source cache of cel-js
programs on the shared evaluation environment, keyed on the source after the
row-dependent rewrites.

Claude-Session: https://claude.ai/code/session_01QNSdgij7mWezAKUbkzSmmR
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 19, 2026
@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

17 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/unknown-function.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/unknown-function.ts) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 9 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 1e8b6284c0b617a0e971884e4ae04197c55e6dfe → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 763da2533c3d874d1a4a94eb22b36a0f99e481c8 — the merge of head 95f434429ab0071ec19d6e1b000e25dedc5a88a6 into base 1e8b6284c0b617a0e971884e4ae04197c55e6dfe, 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 763da2533c3d874d1a4a94eb22b36a0f99e481c8 && git checkout 763da2533c3d874d1a4a94eb22b36a0f99e481c8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1e8b6284c0b617a0e971884e4ae04197c55e6dfe 95f434429ab0071ec19d6e1b000e25dedc5a88a6 && git checkout -B drift-repro 1e8b6284c0b617a0e971884e4ae04197c55e6dfe && git merge --no-ff 95f434429ab0071ec19d6e1b000e25dedc5a88a6

node scripts/docs-audit/affected-docs.mjs --json 1e8b6284c0b617a0e971884e4ae04197c55e6dfe

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 1040a3a0c83adc6adb42c02de368da658eac61a7
Local-runs: none
Scope: .changeset/24536-cel-env-reuse.md, packages/formula/src/cel-engine.ts, packages/formula/src/stdlib.ts, packages/formula/src/cel-env-reuse.test.ts, packages/formula/src/cel-eval-binding-isolation.test.ts (net diff against merge-base 8fbbd50) + @marcbachmann/cel-js 8.0.0 lib/evaluator.js, lib/type-checker.js, lib/operators.js, lib/registry.js, lib/parser.js, lib/macros.js, lib/globals.js, lib/options.js (read from the npm tarball, its sha512 equal to the pnpm-lock.yaml integrity; the checkout has no node_modules), packages/formula/src/index.ts, packages/formula/package.json, tsup.config.ts, packages/formula/src/registry.ts, packages/formula/src/unknown-function.ts, packages/formula/src/capability-predicate.test.ts and permission-predicate.test.ts (unchanged), commit ca426cb (#24478), the callers packages/objectql/src/engine.ts (applyFormulaPlan, expression defaults), packages/objectql/src/hook-wrappers.ts, packages/objectql/src/validation/rule-validator.ts (ruleScope, readsCelRoot, readsUserRoot, option visibleWhen), packages/plugins/plugin-approvals/src/approval-service.ts, packages/plugins/plugin-sharing/src/share-link-service.ts, packages/services/service-automation/src/engine.ts (celScope), docs/qa/platform-checklist/areas/api-backend.json, .claude/skills/pm-dispatch/references/filing-gate.md; card #24536 body and comments 6269577066, 6272538729, 6272586886, 6273859234; PR #24629 body and file list; the 40 check-runs on the head

① Derived judgments

Binding isolation

  • Synchronous end to end: right. A cached program is cel-js's #evaluateAST bound to one AST (evaluator.js parse, #evaluateAST). It checks and evaluates in one synchronous call. Every function on the evaluation environment returns synchronously: the stdlib, registerNumericCoercions and cel-js's builtins. cel-js goes asynchronous only when a VALUE is a Promise (operators.js resolveAstArray, maybeAsyncFirst / maybeAsyncBoth). No registered function returns one, and none of the in-repo callers I read hands one in. Nothing returns a closure or a lazy value. A cached program never holds a binding: each stdlib function calls read() when it RUNS (stdlib.ts bindPermissionPredicate, bindCapabilityPredicate and registerBoundStdLib), and that read reaches activeBinding (cel-engine.ts: the shared environment is buildEnvReading with a reader that returns activeBinding).

  • The slot: right. evaluate saves the binding, sets its own and restores the saved one in a finally that also covers the rewrites, the retry and coerce (cel-engine.ts const outer = activeBinding … activeBinding = outer). cel-eval-binding-isolation.test.ts pins it with three nested evaluations run through a scope getter: one where the inner evaluation returns, one where it throws, and one where it has no subject, which also checks the outer now() and timezone. The dev's ablation B reports those three pins red when the restore is deleted. That result is a claim; I did not re-run it.

  • Unbound after bound, and after thrown: right. Every evaluation sets its own binding and never reads the one before it. With no user, the binding is { subject: undefined, permissions, systemPermissions }, exactly what buildEnv received before. can / hasCapability reach their subject test, and refuse with ACTING SUBJECT, as before. Pinned against a no-binding buildEnv, including after a thrown evaluation.

  • can's identity rule: right. The subject is still scope.current_user, the canonical EvalUser that buildScope mints (after extra is merged). The binding is still built from that scope, not beside it.

  • A cached program re-entered while it is running: WRONG, and it decides this verdict. cel-js keeps per-evaluation contexts ON THE AST:

    • a comprehension node (exists, all, exists_one, map, filter) stores args.iterCtx when it is checked (operators.js:667), and every run calls args.iterCtx.reuse(ctx) (:384, :392);
    • cel.bind stores m.bindCtx the same way (macros.js:195, :204, :216);
    • OverlayContext.reuse returns the SAME object after setting this.#parent = parent (registry.js:1423), and every non-iteration variable read inside the step goes through that parent (registry.js:1460).

    Before this head, each evaluate parsed a fresh AST, so nesting shared nothing. Now one source maps to one AST for every evaluation. Take an evaluation of record.items.exists(i, i.ok && current_user.id == record.owner) for ALICE whose i.ok getter evaluates the same source for BOB, which is the scope-getter nesting this PR's own pins treat as in contract. Bob's run re-parents the shared overlay to Bob's RootContext. When control comes back, Alice's step reads current_user and record from Bob's scope, until her comprehension ends. A read such as current_user.id or positions then answers about Bob, silently. A current_user.can(…) there gets Bob's EvalUser as its receiver against Alice's restored binding, so it refuses loudly. Either way one evaluation's subject is seen by another, which is the one thing this face forbids.

    The slot is right, and the program cache bypasses it. No pin covers this: the nested pins use different sources and no comprehension. No production caller I read nests evaluations: applyFormulaPlan, ruleScope, approvals, share links and automation's celScope all hand in plain data. But celEngine and ExpressionEngine are published exports of @objectstack/formula. Per filing-gate.md, a shipped surface is outside the in-repo radius, where "no victim can be named" is not evidence. The changeset's "Results, error kinds and error messages are unchanged" ships in the CHANGELOG, and it is false for this shape.

The shared environment: right. A cel-js Environment is frozen at construction. Parser.parse resets its input and its limit counters on every call. Each evaluation gets its own RootContext, and #convertCache lives there. The registry's caches (Candidates match and check, type declarations) are keyed on types. Nothing registers on either shared environment after it is built: evaluationEnv is module-private and only parses and runs programs, and canonicalParseEnv is only parsed and checked.

The program cache

  • The key is exactly the rewritten source: right. The key is the string handed to env.parse, after rewriteTemporalEquality(source, scope), rewriteNullableTernary, and the retry's rewriteFaultedOperands. A program is a function of that string and one fixed environment, so the key is complete by construction. A rewrite that depends on the scope writes its own string and gets its own entry. Two pins hold it: the [finding] two §1c-adjacent silent wrong answers that are NOT on the retry path — cross-type in membership, and == against a Date-valued binding #7168 rewrite on alternating rows, and the overload retry on alternating rows.

  • Nothing evaluation-specific captured: WRONG as documented. The comment on evaluationPrograms says a program "holds the AST and the types its check recorded, never a value, so it answers every scope exactly as a fresh parse would". The PR body repeats this ("Those are types again"). It is not so:

    • the overlay contexts above keep the last evaluation's RootContext (its whole scope: record, current_user, automation's vars) plus its iterValue and accuValue, reachable from a module-level map of up to 500 sources;
    • callFn / callRecFn overwrite ast.argTypes with runtime types on every call (operators.js:302, :319).

    In SEQUENTIAL use each of these is overwritten before it is read, so results do match a fresh parse. The failure is the re-entrant one above, plus retention the comment denies.

  • The bound: right. 500 entries, the oldest evicted before an insert, the same shape as temporalRewriteCache.

  • Faults on a hit: right.

    • A parse fault is never stored.
    • A check fault leaves the root's checkedType unset, because type-checker.js:27 assigns it only on success, so the evaluation checker re-runs on every call and raises the same EvaluationError.
    • A runtime fault is raised per call.
    • An unknown function is a check-time no_matching_overload.

    Pinned by the four-case it.each in cel-env-reuse.test.ts.

Compile: right. canonicalParseEnv ??= buildEnv(…), with a clock that returns new Date(0), is the call compile used to make on every compile: the same CEL_ENV_OPTIONS, the stdlib with now fixed at new Date(0), UTC by default, no binding. parseCelToAst builds it with the same arguments. Its check writes only type-keyed registry memos and annotations on the fresh AST it was handed.

Results: right for every non-re-entrant evaluation, wrong for the shape above. The stdlib drift pin, the unknown-function oracle and the #24478 / #18781 predicate pins are untouched. buildEnv and the shared environment build through the same buildEnvReading, so the registered names are identical. #24478's landed contract holds: hasCapability is receiver-only and answers from systemPermissions, bound beside permissions per evaluation. It still refuses on a missing list, a non-list, a non-string or empty name, and a foreign receiver, in the same order, and it is registered unconditionally.

Gates. The 40 check-runs on the head, latest per name: 34 success, 6 skipped (Build Docs, Console Pin Gate, the Dogfood Regression Gate matrix, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance), 0 failing. Latest completion 2026-10-19T18:29Z. The dev's own figures, 141.7 to 3.2 µs, 78 builds to 0 per read, 66 of 66 gate exits and the A1, A2 and B ablations, are claims I did not re-run.

② Semver level

  • @objectstack/formula patch with Clause-②: no: right, and it stays right after the repair. registerBoundStdLib and StdLibBinding reach no published entry:
    • package.json exports is "." only;
    • tsup's entry is src/index.ts;
    • index.ts is untouched, names its stdlib exports one by one (registerStdLib, buildScope, registerPermissionPredicate, registerCapabilityPredicate, the type PermissionBinding) and has no export *.
  • registerStdLib and the two predicate registrars are unchanged for a caller that passes its own values: right. Each is now its reading twin, with a reader that returns the caller's pinned values, so it registers the same names, runs the same checks and gives the same answers. One nit, non-blocking: a caller's now is now called as a method of the pinned object (read().now(), binding.now()), not as a bare call. A clock that reads this would see a different this. Every in-repo caller passes an arrow function.

③ Boundary flags

  • Deviations (5), as reported in 6273859234:

    1. objectql untouched: right; nothing there needed to change.
    2. Two package-internal exports: right; see ②.
    3. Timezone bound per evaluation: right; pinned in "now and timezone are each evaluation's own".
    4. Commit shape: right. The trailers carry no model name.
    5. Write budget: a claim, consistent with what is on the card and the PR.
  • open_questions:

    • (a) Take the program cache: right in principle, wrong as built. The claim 6272538729 allowed it only if measured, with a key carrying every input that changes the program. The key meets that. The premise that a program holds no values does not hold, and the dispatch 6272586886 asked for a read of "what a cel-js Environment holds per evaluation". That read was done for the environment, not for the program, and the defect is in the program.
    • (b) Timezone bound per evaluation: right. One environment serves every zone, with nothing to bound.
  • out_of_scope_finding (collectCelRootIdentifiers builds a fresh environment per call): it meets no filing gate, and carrier: none is right. It is an unmeasured performance observation with no user-visible cost, which filing-gate.md excludes. Its runtime callers are not per row:

    • rule-validator.ts memoises per source (readsCelRoot, readsUserRoot);
    • approval-service.ts calls it once per expression-approver resolution;
    • the rest are lint, which runs at build time.

    If it is ever measured, the domain:engine seat that owns packages/formula would carry it.

  • Promise-valued scope data (non-blocking, worth one sentence in the slot's doc). If a scope value is a Promise, cel-js continues in a .then after evaluate has returned. Any now(), today(), daysFromNow(), daysAgo(), can or hasCapability in that continuation reads UNBOUND: the wall clock, UTC, and no subject, so the predicates refuse loudly. Before this head it read its own environment's pinned values. It never reads another evaluation's binding, because no evaluation is on the stack when a microtask runs. No in-repo caller hands in a Promise.

  • Checklist prose drift (non-blocking). api-backend.formula-stdlib-matrix cites stdlib.ts#registerStdLib as "the 29 chained registerFunction calls", and its step 1 says to re-count them in source. The chain now lives in registerBoundStdLib. The count is still 29 and the anchor still resolves, so no gate moves. A literal re-count of registerStdLib's body finds none. This is for the item's next revision.

  • Stale prose (non-blocking). unknown-function.ts says compile and evaluate "both call buildEnv". Evaluate now builds through buildEnvReading. The registrations are the same, so the oracle still measures the environment that evaluates.

  • No presence pin for the program cache (non-blocking). Its pins are equivalence pins and pass with or without the cache. The card's ablation clause concerns the environment count, which A1 covers.

  • What a passing head needs:

    1. A nested evaluation, one whose saved outer is not UNBOUND, neither uses nor stores an evaluationPrograms entry. It parses its own program instead, or the code adopts an equivalent rule that keeps a running program's AST away from a re-entrant evaluation.
    2. A pin that is red on this head: the same source, nested through a scope getter inside exists(…) and inside cel.bind(…), answers for the outer subject exactly as a fresh parse does.
    3. The evaluationPrograms comment and the PR body's "What a cel-js Environment holds" name the overlay contexts and the runtime argTypes writes, and say that a cached program keeps its last evaluation's scope reachable until the next evaluation of that source.

Implemented-by: claude/issue-24536-cel-env-reuse
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: FAIL


Seat note, appended at posting (2026-10-19T19:20Z). It is not part of the record.

  • Spot-checked in cel-js 8.0.0, the tarball whose integrity matches pnpm-lock.yaml:
    • runQualifier / runComprehension run args.iterCtx.reuse(ctx), and the comprehension's check stores args.iterCtx on the AST (lib/operators.js :384–:392, :667);
    • OverlayContext.reuse sets #parent and returns this when the context is not async (lib/registry.js:1423);
    • every non-iteration read goes through #parent (:1459).
      So one cached program re-entered from inside its own comprehension re-parents the outer run's overlay. The record's ①.4 ground stands.
  • No in-repo caller nests today, as the record says. @objectstack/formula publishes celEngine / ExpressionEngine, though, and the changeset states unchanged results. So the repair is owed on this PR, not as a follow-up.
  • Fix round 1 goes to the dev now with the record's three conditions. A delta record follows on the new head.

cel-js keeps a comprehension's and cel.bind's overlay context, and a call's
runtime argTypes, on the AST. A run re-entered from inside its own
comprehension could re-parent the outer run's overlay and read the inner
run's scope. A run that finds its program already running now parses a
private one.

Claude-Session: https://claude.ai/code/session_01QNSdgij7mWezAKUbkzSmmR
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 95f434429ab0071ec19d6e1b000e25dedc5a88a6
Local-runs: none
Scope: packages/formula/src/cel-engine.ts (runProgram, runningPrograms, the evaluationPrograms and activeBinding docblocks), packages/formula/src/cel-program-reentry.test.ts (new), packages/formula/src/unknown-function.ts (one prose line), and @marcbachmann/cel-js 8.0.0 lib/operators.js (unsupportedType, the comprehension iterCtx, argTypes), lib/registry.js (OverlayContext.reuse) and lib/macros.js (bindCtx), read from the tarball the base record read. This is a delta review on the baseline record 6275342648 (head 1040a3a0c, FAIL: a cached program is not re-entrant).

Inputs:

  • that record;
  • the fix-round report 6275972335, read as claims;
  • the one commit on top of the old head (95f434429, parent 1040a3a0c), read with git diff on fetched refs: 3 files, +126/−5;
  • this head's check-runs.

There is no merge of main. The report says git merge-tree against it shows no conflict.

① Derived judgments

The repair: right. It is the record's "equivalent rule". runProgram now has three arms:

  • Miss: parse, store (oldest out first at 500), then run.
  • Hit on a program not running: run it.
  • Hit on a program already running: run env.parse(source) and store nothing.

A run marks its program in runningPrograms and clears the mark in a finally, so a thrown run leaves no mark. Record 6275342648 ③'s condition 1 allowed either a nested evaluation that never touches the cache, or "an equivalent rule that keeps a running program's AST away from a re-entrant evaluation". This is the second. The hazard is two runs of ONE AST at once, and every env.parse returns fresh AST nodes. So a nested evaluation of a different source, which stays on the cache, shares no iterCtx, bindCtx or argTypes with the outer run.

Paths walked against the guard:

  • Same source, nested: a hit on a running program, so it takes the private parse. Right.
  • Same source, nested twice: each inner level meets the outer level's mark and parses privately; a private program is never marked or stored. Right.
  • The outer source evicted while it runs (more than 500 other sources evaluated inside it): an inner evaluation of it misses and caches a new program, distinct from the one the outer level holds. Right.
  • The overload retry: it runs a rewritten source after the first run has thrown, so the first mark is already cleared. Right.

The guard does not depend on the slot. It keys on the program, so it holds for an unbound nested evaluation too. That is wider than the record's slot-keyed sketch, and correct.

cel-js, re-read for this round:

  • OverlayContext.reuse (registry.js:1422) re-parents the overlay in place unless ctx.async is set.
  • The comprehension stores its context on the AST (operators.js:667, args.iterCtx = ctx), and so does cel.bind (macros.js:195, m.bindCtx).
  • A value of a class with no registered type falls to unsupportedType (operators.js:66), which matches the dev's measured Unsupported type: Promise.

The cited lines in the new docblock and in the dev's PR-body passage are where they say.

The pin has teeth.

  • cel-program-reentry.test.ts nests the same source through a scope getter inside exists(…), map(…) and cel.bind(…), with the cache warmed first.
  • Each case compares the cached run with a fresh buildEnv parse of the same scope.
  • The dev reports it red on 1040a3a0c and red under the ablation else if (false), 3 failed / 1 passed each time, with both restores proven blob-equal to HEAD. These are claims I did not re-run. The pin's shape does reach the guarded arm: the outer run holds the warmed program while the getter's inner run meets it.
  • The fourth case (BOB answers false) is not nested. It is a control, not a pin of the inner run's answer inside the nesting. Non-blocking.

Prose: right.

  • The evaluationPrograms comment no longer says "never a value". It names the overlays, the runtime argTypes writes, and the last scope kept reachable, bounded by the cache's size.
  • The activeBinding doc's new sentence states the measured Promise refusal.
  • unknown-function.ts's line now says both paths build through buildEnv's constructor. That is true, since buildEnvReading is the constructor behind both. {@link buildEnvReading} would name it more exactly. Non-blocking.

② Semver level

@objectstack/formula patch with Clause-②: no still holds. runningPrograms is module-private, no export moves, and index.ts is untouched. Every result and error is what a fresh parse answers, now in the nested case too.

③ Boundary flags

  • Deviations (3), as reported in 6275972335:
    1. The program-keyed guard in place of the slot-keyed one: accepted, see ①.
    2. The record's ③ Promise reading: corrected by measurement. A Promise-valued scope value is refused before any continuation exists, so no Promise-release code is owed. The base record's ③ sentence on Promise continuations is withdrawn.
    3. unknown-function.ts outside the file surface: accepted. It is the one-line prose fix that record ③ offered.
  • open_questions: none this round. The base record's (a) and (b) stand: the program cache is taken and now built right, and the timezone is bound per evaluation.
  • PR body: the seat patches it to this head: the dev's suggested passage for "What a cel-js Environment holds", the "One run at a time" bullet, the new pin, ablation C, and this round's verification line.
  • Gates for this round: 55 derived for this round's files, all exit 0. Formula: suite 51 files / 1367 passed, typecheck and build exit 0. Core test:repo 82 passed. The other 11 of the 66 no-path commands read only the changeset, which is unchanged since 1040a3a0c, where they exited 0.
  • CI on 95f434429: the seat's ACCEPT waits for ci-failure GREEN on this head.

Implemented-by: claude/issue-24536-cel-env-reuse
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-19T20:24Z · ACCEPT

Head: 95f434429a. The commits, in order:

  1. one evaluation environment with a per-evaluation binding slot;
  2. compile reuses canonicalParseEnv;
  3. the bounded per-source program cache;
  4. the changeset;
  5. a merge of main;
  6. fix round 1: a cached program serves one run at a time.

Card #24536 (p1, bug, area:records): claim 6272538729, dispatch 6272586886. Dev reports: 6273859234, and fix round 1 6275972335.

Item Reading
Shape Draft PR → main. The body opens Fixes #24536. 7 files, +604/−62. Not governed: check-governed-merges matched 0 of 7 paths.
Scope celEngine.evaluate builds no cel-js Environment per call. now, the timezone and the PermissionBinding are bound per evaluation through the unexported activeBinding, restored in a finally. compile reuses canonicalParseEnv. A per-source program cache (500, oldest out first) is keyed on the rewritten source. A program serves one run at a time (runningPrograms), so a nested evaluation of the same source parses a private program. Measured: 141.7 → about 3 µs per evaluate, and 78 → 0 environment builds per 25-row read.
Seat's own read The fix commit's guard was walked on four paths: nested once, nested twice, the outer source evicted while it runs, and the overload retry. The cel-js lines it rests on were checked in the 8.0.0 tarball: reuse re-parents in place (registry.js:1422), iterCtx and bindCtx live on the AST (operators.js:667, macros.js:195), and a Promise value falls to unsupportedType (operators.js:66). The PR body was patched to this head (the dev's passage, the new bullet, the pin, ablation C, and this round's verification).
Review TIER_DEFAULT. Record 6275342648 was FAIL (a cached program is not re-entrant: cel-js keeps comprehension and cel.bind state on the AST; one comment was false). The delta 6276125243 is PASS on this head.
Declaration Clause-②: no on the claim, the PR body and the changeset (@objectstack/formula patch). registerBoundStdLib and StdLibBinding are exported from stdlib.ts only. exports is "." and index.ts is untouched, so no published entry moves.
Cross-lane None. Every path is in packages/formula, this lane's own package. No objection on the card.
Gates Dev, at 1040a3a0c: 66 derived, 66 run, all exit 0. Fix round at 95f434429: the 55 commands derived for its files, all exit 0. Formula suite 51 files / 1367 passed. Core test:repo 82 passed. objectql suite 8414 passed (the fix round did not touch objectql).
CI At this read on 95f434429a: ci-failure GREEN, all 40 check-runs completed, none failed.

Disposition:

  • open_questions: both taken as the dev settled them, per record 6275342648 ③ and the delta: (a) the program cache, now built right; (b) the timezone bound per evaluation.
  • deviations: all accepted (record 6275342648 ③ and delta 6276125243 ③). The base record's sentence on Promise continuations is withdrawn by measurement.
  • out_of_scope_findings: collectCelRootIdentifiers building a fresh environment per call meets no filing gate (record ③); not filed.
  • Non-blocking notes, for the next touch of packages/formula:
    • unknown-function.ts could link buildEnvReading by name;
    • the checklist item api-backend.formula-stdlib-matrix re-counts registerStdLib's body, and the chain now lives in registerBoundStdLib;
    • the program cache has equivalence pins but no presence pin.

Downstream: the repo:cloud#2 seat is pointed at the landing for cloud#3053's measurement.

Landing: ordinary queue (pr_ready + automerge_enable through the relay). Fixes #24536 then closes the card.


Generated by Claude Code

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/l tests tooling

Projects

None yet

2 participants