Skip to content

lint: refuse a visibility predicate calling a function the CEL environment does not register - #14359

Merged
os-musk merged 3 commits into
mainfrom
claude/issue-13594-visibility-predicate-function-existence
Sep 2, 2026
Merged

lint: refuse a visibility predicate calling a function the CEL environment does not register#14359
os-musk merged 3 commits into
mainfrom
claude/issue-13594-visibility-predicate-function-existence

Conversation

@os-musk

@os-musk os-musk commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes #13594

Implements the ruled branch one — director batch #21, ruling comment 5479573383, executed 5479679994. The six refinements there are binding and are not re-adjudicated here.

NOT MEASURED — read the whole change inside this bound

Carried forward per ruling point 4, declared to the maintainer before the ruling and accepted with it. The census that supplied the premise (host-registered extra CEL functions = 0, positive control firing) reached objectstack, objectui and one shipped host app. It did NOT reach:

  • cloud — unreachable to the census seat. NOT MEASURED.
  • objectos — unreachable to the census seat. NOT MEASURED.
  • Published third-party apps — outside every reachable corpus. NOT MEASURED.

cloud and objectos are precisely the repos that carry host configuration. If a host in either registers extra CEL functions, the predicates using them are now refused at objectstack validate. The measured 0 is a 0 about three reachable repos, not about the platform.

  • objectui's own template-function registry (FormulaFunctions) is a DIFFERENT engine and is not in this gate's jurisdiction — spelled out so the census's zero is not read as covering it. ExpressionEvaluator routes a { dialect: 'cel' } envelope to @objectstack/formula and passes no functions; registerFunction there feeds only the template branch.

The premise, re-measured on this tree before any code was written

Zone 2.1 required both halves measured. Both hold.

(a) validateExpression already refuses unknown calls — so the card's title premise (that the ADR-0032 validator was the hole) stays falsified, as two seats found independently:

"totallyBogusFn(1,2)"            ok=false  errors=1  | invalid CEL predicate: found no matching overload for 'totallyBogusFn(int, int)'
"record.x.nosuchmethod('a')"     ok=false  errors=1  | invalid CEL predicate: found no matching overload for 'dyn.nosuchmethod(string)'
"upper('a')"                     ok=true   errors=0  |
"current_user.can(object, verb)" ok=false  errors=1  | invalid CEL predicate: found no matching overload for 'dyn.can(dyn, dyn)'

(b) the lint gate passed the same sources CLEAN — measured on one fixture stack carrying six predicates, so the two CLEAN rows sit beside two rows that FIRE and are therefore a reading, not a blank run:

predicate                       BEFORE (this gate)                          AFTER
totallyBogusFn(1,2)             CLEAN                                       visibility-predicate-unknown-function (error)
record.x.nosuchmethod('a')      CLEAN                                       visibility-predicate-unknown-function (error)
bogusPageFn(1)   [page cmp]     CLEAN                                       visibility-predicate-unknown-function (error)
upper(record.name) == 'X'       CLEAN                                       CLEAN                       <- control
country === "USA"               visibility-predicate-syntax (error)         unchanged                   <- control: gate reached
status == 'active'              visibility-bare-identifier (error)          unchanged                   <- control: gate reached

What changed

@objectstack/formula — one new export, firstUnknownFunctionCall(source) (plus its UnknownFunctionCall type), in a new src/unknown-function.ts.

It answers exactly one question — does this source call a name the evaluation environment does not register — and returns null for every other outcome, so no consumer has to grade a check() fault itself. The oracle is the environment (refinement 1: 「oracle = 引擎实际注册集(cel-js check() 裁定),⛔ 不是 CEL_STDLIB_FUNCTIONS 常量。」), read through the same buildEnv seam celEngine.compile and celEngine.evaluate build with. CEL_STDLIB_FUNCTIONS is untouched — #13933 owns the 35-vs-72 drift.

The cel-js found no matching overload for '…' extraction moved into this module and validate.ts now imports it, so the two readers of that message cannot drift on which token cel-js named. validate.ts keeps its own separate question (is the name ADVERTISED, for wording a hint) — that behaviour is unchanged and its pins are green.

@objectstack/lint — one new rule, visibility-predicate-unknown-function (severity error) in validate-visibility-predicates.ts, covering both call forms. The message quotes the engine's own verdict verbatim so publish time and run time read as one system, and carries no "did you mean" suggestion (refinement 2: 「不给 nearestName 建议。」 — nearest-name over the function namespace answers min for can).

The module note was rewritten, not edited around (refinement 4). The old parse-only ruling is quoted intact as a block quote, then narrowed: every sentence in it still holds and visibility-predicate-syntax still gives only the parse verdict; what changed is that one verdict inside compile() turned out not to be a type-check. A new section carries the evidence (objectui#4421 plus the before/after table above), this ruling's authority, and the four boundaries. The pinned CEL-type blind spot is narrowed in the test that pins it — its title now reads "…stays blind, function existence excepted" and it asserts the narrowing directly, so the change is visible where the old absolute claim was rather than silently elsewhere.

Scope discipline — what still stays blind (refinement 3)

「只拒未知函数裁定,⛔ 不搬运 check() 的其他抱怨。」 Each row measured and pinned:

source verdict why
type(record.x) == string clean legal dyn CEL, type-checks — the shape the blind-spot pin protects
type == 'grid' clean refused as no such overload: type == string — not a call verdict
1 + 'a' clean no such overload: int + string — no call in it
upper(1, 2) clean upper IS registered; arity/type fault, not existence
split('a,b') clean split IS registered, receiver-only; a call-FORM fault, not existence
record.name.split(',') clean the same name used correctly
record.created.getFullYear() > 2020 clean registered but not advertised — the 37-name gap

That last group is why the membership set is every registered name, bare-callable and receiver-only alike: narrowing it to the bare-callable 39 would turn call-form mistakes into existence claims that are false.

Refusing an unregistered call is not a false positive. Validation and runtime are the same builder — compile and evaluate both call buildEnv — measured by the census at 53 probes, 0 divergent verdicts. A call this refuses would have faulted.

Positive controls

  • All 72 registered names, both call forms, zero misjudged. unknown-function.test.ts enumerates the registration set off buildEnv().getDefinitions() (measured, not transcribed — the same seam cel-stdlib-drift.test.ts uses, and deliberately not read back out of the module under test) and probes each name as NAME(record.x) and record.x.NAME(record.y). The rule never calls a registered name unknown. A companion case derives the registered-but-unadvertised gap from the two sets and fails if it ever empties, so the control cannot go vacuous; a negative control proves the same probe shape DOES refuse a genuinely absent name.
  • At the gate: twelve names drawn from that gap (type, map, filter, split, getFullYear, json, substring, indexOf, lowerAscii, exists_one, hasValue, orValue) each asserted absent from CEL_STDLIB_FUNCTIONS first, so no row can pass vacuously.
  • Runtime publish door: runtime-gate.test.ts drives runRuntimeAuthoringRules — the same function metadata-protocol's runtime authoring gate calls — with a view body a Studio / REST / MCP author could save, and pins both the refusal and its accept control. A rule that only worked through validateVisibilityPredicates and never reached the door would be a declaration nobody reads.

Corpus sweep (Zone 2.4)

examples/** + apps/**, every authored visibility predicate extracted (both quote styles, the { source } envelope and the tagged-template P form) and judged:

scope: examples/ apps/
files scanned (containing a visibility key): 12 of 254 tracked
predicates extracted: 15
NEW refusals (visibility-predicate-unknown-function): 0
positive control (totallyBogusFn): FIRED

Widened to packages/ content/ docs/ as well: 302 predicates, 4 refusals — three are this PR's own new negative fixtures, and the fourth is a doc-comment inside packages/lint/scripts/check-doc-formula-expressions.mjs that quotes user.hasRole('admin') as a deliberate specimen of this exact defect class. Zero authoring defects found; nothing fixed out of scope.

Verification

Head the union was run on: ac34084d2.

  • pnpm --filter '@objectstack/formula' exec vitest run27 files, 712 tests passed
  • pnpm --filter '@objectstack/lint' exec vitest run93 files, 2749 tests passed
  • pnpm --filter '@objectstack/formula' --filter '@objectstack/lint' typecheck — both Done
  • pnpm lint (repo-wide eslint . --no-inline-config) — exit 0, no narrowing claimed
  • 33 derived gate families (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived after the last commit; the PM hint listed 19 and the derived set is a superset). 31 exit 0. Two are PREREQUISITE NOT MET (exit 3, the gates' own distinct code, captured before any pipe) and are recorded NOT MEASURED, never as a pass: check-test-completeness.mjs grades a saved turbo run test log CI tees and no local run produces, and scripts/pm/check-half-states.mjs needs repo-scoped GitHub reads this container is refused (/rate_limit 200 with 15000 left, /repos/… 403 — the session gate, not quota). The two that were exit 3 for a missing dist/ were re-run after building the workspace closure and both pass.

Verdict lines, quoted from the gates rather than from an exit code:

check-test-source-alias OK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through `dist/`
check-type-source-resolution OK — 96 tsc program(s) across 77 packages scanned
check:published-files — 69 publishable package(s) ... declare a `files` whitelist ...
check:dual-build-cjs-loads — 102 published require entry point(s) across 66 package(s) load; 610 emitted CommonJS file(s) parse
check-type-check-coverage --re-measure: OK — 27 ledger entr(ies) re-measured, 1217 raw tsc error(s) total, none above its recorded number
check:undeclared-dep-imports: 78 workspace packages ... 2 ledger row(s), all evidence intact
check:doc-authoring: sibling-package prose ids hold the baseline — 831 pinned site(s), no growth, no burn-down unrecorded
check-nul-bytes: OK (scanned 7826 text file(s) ... no raw ASCII control bytes)

One real RED was found and repaired rather than worked around: check:doc-authoring refused the tracker ids in the new runtime message string (objectui#4421 new, one more #5149 above a 3-pin baseline). A runtime string reaches authors and operators who cannot resolve #NNNN; the ids moved to the adjacent comment and the module note. Adding baseline entries is maintainer-only and was not taken.

Ablations — both legs rebuild-free by construction, both restored byte-identically

Both suites import their subject through a relative source specifier inside the same package, so vitest resolves the source file and no rebuild is involved; each mutation was proven on disk before the run and each restore proven after.

ablation mutation proof result
lint arm neutered (firstUnknownFunctionCall(source) replaced by null) injected-marker=1, removed-call=0, blob e51664b6 to f554e875 12 tests RED across both lint test files
formula oracle neutered (early return null) injected-marker=1, blob 1da7806c to 43e1c9ab 6 tests RED in unknown-function.test.ts

Restore is git checkout HEAD -- ABSOLUTE_PATH inside an EXIT INT TERM trap; proven by git diff HEAD empty and git hash-object equal to the HEAD blob for both files after each leg.

⚠️ Stated rather than implied: the lint suite resolves @objectstack/formula through its dist/ (it is a registered row in KNOWN_UNALIASED_TEST_IMPORTS), so the formula ablation was deliberately run against the formula suite only. It is not evidence about the lint suite and is not offered as such.

NOT MEASURED in the verification itself

Both packages' typecheck excludes every *.test.ts, so the green above says nothing about the three test files this PR writes or edits. Measured separately with a program that includes them (tsc --listFiles confirms all three are in it): zero errors in this PR's test files. The pre-existing errors that program reports — 9 in five packages/formula test files, 16 in five packages/lint test files — are the already-filed class (#12511 / #4311), untouched here.

Clause-② self-reading, from the diff

yes, on both limbs the PM declared, and the PR carries needs:contract-review per ruling point 6.

  • Accept set narrows. An authored predicate calling a function the engine does not register is now refused at objectstack validate and at the runtime publish door. Population measured at zero in every reachable corpus; unbounded for cloud / objectos / third-party apps per the hat above.
  • Public surface widens. @objectstack/formula gains firstUnknownFunctionCall + UnknownFunctionCall; @objectstack/lint gains VISIBILITY_PREDICATE_UNKNOWN_FUNCTION. Both packages take minor in one changeset.

Declared file surface — one amendment, stated rather than done quietly

The claim declared four files. This PR touches a fifth: packages/lint/src/index.ts, one line, exporting the new rule-id constant. Its three siblings (VISIBILITY_PREDICATE_SYNTAX, VISIBILITY_PREDICATE_OVER_BUDGET, VISIBILITY_BARE_IDENTIFIER) are exported on the adjacent lines, and --json consumers and allowlists key on the id — an id reachable only as a string literal is the inconsistency, not the fix. Same defect class, mechanical, correct shape pinned by the three siblings, no new verification surface. packages/lint/src/validate-expressions.ts — the declared ⛔ — is not touched.

packages/lint/src/runtime-gate.test.ts is likewise beyond the four named files and inside "both packages' tests": it is where the publish-door pin belongs, beside its siblings.


🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…ered CEL function

`validate-visibility-predicates` was parse-only by design, so a predicate that
parses perfectly and calls a function the CEL environment does not register
passed the publish gate CLEAN — measured with two controls that fired
(`country === "USA"` -> syntax, `status == 'active'` -> bare-identifier) while
`totallyBogusFn(1,2)` and `record.x.nosuchmethod('a')` produced nothing. The
runtime fault it hides falls OPEN on a view/page surface and CLOSED on an
action surface, where the action disappears for every user including grant
holders behind one deduped console.warn (objectui#4421).

Maintainer ruling 2026-08-31 (director batch #21), on a censused premise
(host-registered extra CEL functions in the reachable corpus = 0, positive
control firing): extend the gate to report an unknown-function call as an
ERROR. Scoped supersession of the parse-only ruling -- function existence only.

- @objectstack/formula: new `firstUnknownFunctionCall(source)`. The oracle is
  the evaluation environment's own registration set, read through the same
  `buildEnv` seam `celEngine.compile` and `celEngine.evaluate` use -- never the
  advertised `CEL_STDLIB_FUNCTIONS` catalog, which lists 35 of the 72
  registered names and would have refused 37 functions that evaluate today.
  The cel-js `found no matching overload for '...'` extraction moves here so
  this module and `validate.ts` cannot drift on which token was named.
- @objectstack/lint: new `visibility-predicate-unknown-function` (error),
  covering the global and receiver/member call forms. Quotes the engine's own
  wording verbatim; offers no "did you mean" suggestion (nearest-name over the
  function namespace answers `min` for `can`).

Everything else `check()` complains about stays unread: a registered name
called with wrong arguments (`upper(1, 2)`), a registered name called in the
wrong position (bare `split('a,b')`), the CEL-type blind spot (`type ==
'grid'`) and operator-overload faults (`1 + 'a'`) are all still silent, and
`type(record.x) == string` is untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…essage

`check:doc-authoring` refuses an internal issue id in a string a runtime
surface hands an author: 2 new (file,id) pairs above the ledger baseline
(`objectui#4421`, one more `#5149`). A runtime string reaches authors and
operators who cannot resolve `#NNNN`; the anchors move to the adjacent comment
and the module note, where the reader who can resolve them is.

Prose otherwise unchanged, both consequences still named.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions github-actions Bot added the size/l label Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

10 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ⚠️ 2 changed file(s) yielded no anchor (packages/formula/src/index.ts, packages/lint/src/index.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
  • 2 changed file(s) yielded no anchor (packages/formula/src/index.ts, packages/lint/src/index.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 — 11 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 8094834060c0606c9c62aabf24e3bec380c9a3d6packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 8094834060c0606c9c62aabf24e3bec380c9a3d6

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

os-musk commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Provenance for the ready flip + auto-merge: contract review PASS in-seat at tier on ac34084d (issue comment 5503790050 on #13594, under the 2026-08-31 in-seat review ruling; the maintainer's 2026-08-31 branch-one ruling is the direction); every check run on this head completed success/skipped at 03:28Z; both needs:contract-review carriers cleared in the same stroke. Queue landing (squash) follows.


Generated by Claude Code

@os-musk
os-musk enabled auto-merge September 2, 2026 03:34
@os-musk
os-musk added this pull request to the merge queue Sep 2, 2026
Merged via the queue into main with commit 038f333 Sep 2, 2026
51 checks passed
@os-musk
os-musk deleted the claude/issue-13594-visibility-predicate-function-existence branch September 2, 2026 04:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants