Skip to content

fix(formula,lint): current_user 收进 SCOPE_ROOTS,字段级拒绝改为按面的真规则 (#6290) - #6584

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-6290-current-user-scope-roots
Aug 8, 2026
Merged

fix(formula,lint): current_user 收进 SCOPE_ROOTS,字段级拒绝改为按面的真规则 (#6290)#6584
baozhoutao merged 2 commits into
mainfrom
claude/issue-6290-current-user-scope-roots

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #6290

packages/formula 对同一个根说了两套话:introspectScopecurrent_user 当合法命名空间交给作者、checkRoleCatalog 四条正则全以它打头(两处都对 —— ADR-0068 D1 把它定为规范拼写,buildScope 也确实挂了它),只有 cel-engine.tsSCOPE_ROOTS 不认。于是严格环境把被祝福的拼写读成裸字段引用,而它的两个别名(userctx)一路放行;拒绝时给出的还是通用裸字段处方 ——「Write record.current_user」,一个在平台任何一层都不绑定的形状。

按 PM 裁决落地:收下进 SCOPE_ROOTS,字段级判定改为按面的真规则,并补齐 option 级遍历。


前提复核表(动手前逐条实测)

# 前提 结论 证据
P1 origin/main 三处事实原样 ✅ 成立 cel-engine.ts:54-68SCOPE_ROOTSusercurrent_user;validate.ts:427introspectScope 返回 ['record','previous','input','os','current_user','user','vars'];validate.ts:261-269 四条 ROLE_*_RE 全写 `(?:current_user
P2 ADR-0068 把 option 级 visibleWhencurrent_user 定为合法面 ✅ 成立(经由 spec 与 ADR 两侧) ADR-0068 D1:「同一谓词在 formula / RLS / 客户端 visible gate 求值结果相同」,规范变量名 current_user;spec 侧写得更死 —— field.zod.ts:129-143SelectOptionSchema.visibleWhen JSDoc:「options resolve through resolveCascadingOptions against the predicate scope (ADR-0068 / objectui#2284)… Per-option is the one *When surface where a current_user test actually resolves」
P3 裁决前提:收下后字段级拒绝仍可按「面」维持 ✅ 成立(见下节实测) 耦合确实存在,但可调和
P4 与在飞 #6146 零文件交集 ✅ 成立,且 #6146 已落地 #6146 已于 2026-08-07 15:32Z closed,PR #6315 = commit 8a88885cb,只动 packages/spec/src/{data/field.zod.ts,ui/view.zod.ts} + content/docs/** + changeset;本单动 packages/formula/src/cel-engine.tspackages/lint/src/validate-expressions.ts,交集为空。推前已 merge origin/main(至 a36db28b7),复核 #6146 的口径原样保留

P3 —— 裁决前提的实测细节

耦合方向确认(裁决前提所担心的那半边,确实存在):字段级 visibleWhencurrent_user 的拒绝,今天完全来自 validateExpression(…, {scope:'record'})firstUndeclaredReferencebuildScopedEnv 注册 SCOPE_ROOTS。收下 current_user 后该路径不再报:

# origin/main(收下之前)
'admin' in current_user.positions
  validateExpression.ok= false ["bare reference `current_user` — … Write `record.current_user`."]
  firstUndeclaredReference= "current_user"

# 收下之后
'admin' in current_user.positions
  validateExpression.ok= true []
  firstUndeclaredReference= null

但两者可调和 —— 裁决成立,不作废。 判据是同包另一个 helper 的结构性独立:collectCelRootIdentifiersbuildEnv(unlistedVariablesAreDyn: true)读 AST,完全不读 SCOPE_ROOTS,收下前后同答案:

# 收下之前 / 之后,两次都是:
collectCelRootIdentifiers("'admin' in current_user.positions") = { ok: true, roots: ['current_user'] }

所以「按面区分」不是勉强绕开,而是本仓已有的既定机制:#3447 P2 的审批节点 approver 就用 collectCelRootIdentifiers 表达闭合根集,validate-visibility-predicates.ts:418 用逐面 VIEW_PAGE_EXTRA_ROOTS 表达同一件事,而 validate-expressions.ts:81 早已 import 了这个 helper。

结论:SCOPE_ROOTS 是「永不 fault」的基线(其自身 doc-comment 就是这么写的:「an unknown root is a missed catch, a missing root is a false positive that would break the build」),不是逐面契约;逐面闭合由面自己声明。收下 current_user 之后,字段级判定不但仍可维持,而且比原来更正:原来它是基线遗漏的副产物,所以只能借通用文案说话 —— 那正是错误处方的成因。

三个半边

1. SCOPE_ROOTS 收下 current_user(packages/formula/src/cel-engine.ts)

一词之改 + 成因注释。新增行为钉(不是列表相等断言,SCOPE_ROOTS 作为基线本就可以多于它对外宣告的):introspectScope 报出的每个根,都必须能在严格环境里解析。

2. 删错误修法提示 —— 判定搬到面上(packages/lint/src/validate-expressions.ts)

新增 checkFieldRuleUserRoot,只作用于字段级 visibleWhen / readonlyWhen / requiredWhen(三者共用一个求值器)。新处方原文:

`visibleWhen` reads `current_user`, but a field-level conditional rule binds only `record` (plus `previous`, and `parent` on a master-detail line item) — `current_user` is unbound here, so the predicate faults and falls back to VISIBLE, leaving the field the test was meant to hide showing for everyone (#6146). To gate the CHOICES of a select by user, move the predicate to the option's own `visibleWhen` (`options: [{ …, visibleWhen: … }]`) — per-option is the one `*When` surface that binds `current_user`. To hide the FIELD by role, declare field-level security on a permission set (`fields: { '< object >.< field >': { readable: false } }`), which the server enforces. To gate on record state, rewrite the predicate against `record`.

三条处方逐条落到实际存在的面上,而非文案改写:option 级 visibleWhen = SelectOptionSchema.visibleWhen(field.zod.ts:143);字段级安全 = PermissionSetSchema.fields(permission.zod.ts:455,FieldPermissionSchema.readable);record 改写 = #6146 收窄后的字段级口径。测试用一条否定断言 + 三条肯定断言钉住(「文案变了」不是要保的性质,「文案指向真的绑定的形状」才是)。

3. option 级遍历补齐(同文件)

f.options[].visibleWhen 首次被走查,按 option value 定位。与宿主字段同为 record scope —— 收下 current_user 之后,两个面的全部差别就落在 checkFieldRuleUserRoot 这一条上,而这正是它们该有的差别(求值器不同:evalFieldPredicate vs resolveCascadingOptions)。

读的是字面 f.options 而非 (f as AnyRec).options:后者会让 #5017 的 meta-guard 源码扫描看不见这个新面(该文件自己就写着这条纪律)。相应更新了 meta 表:f 的期望读集加入 options,新增 optSelectOptionSchema 行。

反向验证表(方向先写死,再实测)

预测在动手前落笔,与实测逐条比对。

回退 A —— 从 SCOPE_ROOTS 删掉 'current_user'(其余保留)

测试 预测 实测
formula: every root introspectScope advertises really resolves
formula: role-catalog verdict reachable through every user spelling
lint ①: still REJECTS a field-level visibleWhen 红 —— 不是因为拒绝消失,而是因为拒绝翻倍(通用裸引用错误回来了,和新规则并排) :expected [ { …(4) }, { …(4) } ] to have a length of 1 but got 2 ✅ 连红的原因都对上
lint ①b: prescribes surfaces that exist — never record.current_user 红,且 [0] 就是那条字面写着 Write \record.current_user`` 的通用文案 :expected 'bare reference \current_user` — a for…' not to contain 'record.current_user'` ✅ 本 issue 的缺陷被原样复现
lint ②: ACCEPTS the showcase role-gated OPTION 红 —— 合法用法开始失败 :expected [ { …(4) } ] to have a length of +0 but got 1
lint ③: option 遍历正向证据(5 条) 绿(不受影响) 绿

未列入预测表、同向连带转红的一条:rejects it on readonlyWhen / requiredWhen too —— 同一「翻倍」成因(4 条而非 2 条)。如实记录,非预测命中。

回退 B —— 删掉 option 级遍历循环(保留 SCOPE_ROOTS 改动)

测试 预测 实测
lint ③: option 遍历正向证据(5 条) (5 条全红,expected [] to have a length of 1)
lint ②: ACCEPTS the showcase role-gated OPTION 绿 —— 且是空绿toHaveLength(0) 通过是因为什么都没走,不是因为判定对(#5046 的空绿陷阱) 绿 ✅ 预测方向与成因一致
lint ①/①b 绿 绿
formula 两条钉 绿 绿

未列入预测表、额外转红的两条:#5017 meta-guard 的 every key read off 'opt'the field receiver reads only declared keys —— 源码扫描独立于行为发现了「读没了」。属于额外收益,如实记录。

这就是 ② 必须与 ③ 并存的理由:② 单独看,在回退 B 下是空绿的;真正证明遍历跑起来的是 ③ 产出的 finding。

命令输出

$ pnpm --workspace-concurrency=2 --filter @objectstack/formula --filter @objectstack/lint test -- --maxWorkers=2
packages/formula test:  Test Files  18 passed (18)
packages/formula test:       Tests  424 passed (424)
packages/lint test:  Test Files  62 passed (62)
packages/lint test:       Tests  1596 passed (1596)

$ pnpm --workspace-concurrency=2 --filter @objectstack/formula --filter @objectstack/lint typecheck
packages/formula typecheck: Done
packages/lint typecheck: Done

$ node scripts/check-nul-bytes.mjs
check-nul-bytes: OK (scanned 6163 tracked text file(s); … no raw ASCII control bytes).

$ turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2
 Tasks:    70 successful, 70 total

$ pnpm check:type-check-debt
check-type-check-coverage: OK — 62/77 workspace packages type-checked …
check-type-check-coverage --re-measure: OK — 34 ledger entr(ies) re-measured …, none above its recorded number.

真实 metadata sweep(半边 3 是激活一条检查,同 #5026,必须扫真栈):

$ (examples/app-showcase) pnpm validate   →  ✓ Validation passed (1267ms)   exit=0
$ (examples/app-crm)      pnpm validate   →  ✓ Validation passed (329ms)    exit=0
$ (examples/app-todo)     pnpm validate   →  ✓ Validation passed (263ms)    exit=0

零新增 finding —— 包括同时携带 record 级联选项与角色门控 current_user 选项的 showcase_cascading_select

消费半径外扩(SCOPE_ROOTS 加宽会改动所有 record scope 面的判定,故不止本两包):

$ pnpm --filter @objectstack/cli test   →  Test Files  91 passed (91)   Tests  928 passed (928)
$ pnpm --filter @objectstack/mcp test   →  Test Files   9 passed (9)    Tests   97 passed (97)

说明

  • user / ctx.user 别名在字段级仍静默放行,本 PR 不动 —— 那是收下 current_user 之前就存在的洞(两者一直在 SCOPE_ROOTS 里),不是本次引入的回归,且 ctx 还是 ActionEngine 的根,爆炸半径另算。已按纪律另立 finding,不在本 PR 修。新文案指的是而非拼写,所以不会把作者推去写 user.positions
  • 未触 objectql,check:engine-double-contract 一类 objectql 面门禁不适用。
  • type-check-debt 台账只缩不抬:本次未抬高任何条目(@objectstack/lint TEST_DEBT 记 42、实测 19,该盈余为改动前既有,不属本单)。

Generated by Claude Code

claude added 2 commits August 8, 2026 05:47
`@objectstack/formula` 对同一个根说了两套话。`introspectScope` 把
`current_user` 作为合法命名空间交给作者,`checkRoleCatalog` 的四条
position 成员判定正则也全以它打头 —— 两处都对:ADR-0068 D1 把
`current_user` 定为**规范**拼写,`buildScope` 也确实把同一个 `EvalUser`
挂在它下面。只有 `cel-engine.ts` 的 `SCOPE_ROOTS` 不认,于是严格环境把
这个被祝福的拼写读成**裸字段引用**,而它的两个别名(`user`、`ctx`)
一路放行。

三处改动:

1. `SCOPE_ROOTS` 收下 `current_user`。该表是「永不 fault」的基线,不是
   逐面契约,现在它宣告的与本包别处宣告的一致。新增行为钉:
   `introspectScope` 报出的每个根都必须能在严格环境里解析。

2. 删掉错误修法提示。旧拒绝是基线遗漏的副产物,作者拿到的是**通用**
   裸字段诊断 ——「Write `record.current_user`」。这个形状在平台的任何
   一层都不绑定,照做的作者得到的东西比原来更糟,而且照样静默。字段级
   判定现在由 `@objectstack/lint` 里一条自己的规则给出,写明真实失败链
   (未绑定 ⇒ fault ⇒ 可见性 fallback 为 `true` ⇒ 本想藏起来的字段对所有
   人恒可见,#6146),并给出**真实存在**的处方:把谓词移到选项自己的
   `visibleWhen`、在权限集上声明字段级安全
   (`fields: { '<object>.<field>': { readable: false } }`)、或改写成
   `record` 谓词。覆盖共用同一求值器的 `visibleWhen` / `readonlyWhen` /
   `requiredWhen`。

3. option 级 `visibleWhen` 首次被校验。`validate-expressions.ts` 走完
   字段级条件规则就停了,于是 `SelectOption.visibleWhen` —— 一个客户端
   过滤、服务端强制的可授权 CEL 槽 —— 穿过 compile / validate / 运行期
   无人校验。裸字段引用、指向不存在字段、语法错误、误用 template 方言
   全部静默通过,选项只是从此不再出现。现在按 option value 定位逐条走查,
   与宿主字段同为 `record` scope。

两个面**故意**对 `current_user` 给出相反判定,因为求值器不同:字段级走
`evalFieldPredicate`(`record` + `previous` + `parent`,从不绑用户),
option 级走 `resolveCascadingOptions`,对宿主 predicate scope 求值,确实
绑定它(ADR-0068 / objectui#2284)。showcase 的角色门控选项
(`'admin' in current_user.positions`)此前从未撞上本规则,现在作为合法
用法被钉住。

Sweep:option 遍历生效后,三个示例应用(`app-showcase` / `app-crm` /
`app-todo`)的 `objectstack validate` 全部通过 —— 零新增 finding,包括
同时携带 record 级联与角色门控选项的那个 showcase 对象。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 8, 2026 5:54am

Request Review

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/formula, @objectstack/lint.

9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx (via @objectstack/lint)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/formula)
  • content/docs/data-modeling/validation.mdx (via @objectstack/formula)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint)
  • content/docs/plugins/packages.mdx (via @objectstack/formula)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/formula)
  • content/docs/releases/v15.mdx (via @objectstack/formula)
  • content/docs/releases/v16.mdx (via @objectstack/formula)
  • content/docs/releases/v17.mdx (via @objectstack/lint)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 8, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review August 8, 2026 06:07
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 8, 2026
Merged via the queue into main with commit e9b5265 Aug 8, 2026
25 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-6290-current-user-scope-roots branch August 8, 2026 06: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