Skip to content

fix(core): ActionRunner 的 condition 门改问「有没有声明」,condition: false 真的拦住动作 (#3872) - #3916

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3872-condition-gate-declared
Aug 9, 2026
Merged

fix(core): ActionRunner 的 condition 门改问「有没有声明」,condition: false 真的拦住动作 (#3872)#3916
yinlianghui merged 1 commit into
mainfrom
claude/issue-3872-condition-gate-declared

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3872

机理

ActionRunner.execute —— 引擎的公共执行入口,所有动作面共用 —— 把 condition 门开在真值上:

if (action.condition) {
  const shouldExecute = this.evaluator.evaluateCondition(action.condition);
  if (!shouldExecute) return { success: false, error: 'Action condition not met' };
}

真值回答不了门要问的那个问题(「作者到底声明了条件吗」),而在这个键上它答错的方向是过度放行:condition: false 落在 if (false),整段被跳过,evaluateCondition 根本没被问过,动作照常执行。

基线复现(worktree @ origin/main = 2937bcf7db0da9d27cd4f330a7033d2b380d2335,handler 自计数,一次性探针未提交)

condition: false                                     | handler ran: true  | {"success":true}
condition: 0                                         | handler ran: true  | {"success":true}
condition: true                                      | handler ran: true  | {"success":true}
condition: '' (empty)                                | handler ran: true  | {"success":true}
condition: '   ' (whitespace)                        | handler ran: true  | {"success":true}
condition: {cel,''} (empty envelope)                 | handler ran: true  | {"success":true}
condition: {} (junk object)                          | handler ran: true  | {"success":true}
condition: bare CEL true  (user.role == "admin")     | handler ran: true  | {"success":true}
condition: bare CEL false (user.role == "guest")     | handler ran: false | {"success":false,"error":"Action condition not met"}
condition: {cel,'true'}                              | handler ran: true  | {"success":true}
condition: {cel,'false'}                             | handler ran: false | {"success":false,"error":"Action condition not met"}
condition: template true  (record.status === active) | handler ran: true  | {"success":true}
condition: template false (record.status === inactive)| handler ran: false | {"success":false,"error":"Action condition not met"}
condition ABSENT                                     | handler ran: true  | {"success":true}

issue 的前提逐行成立:false 与语义完全相同的 { dialect: 'cel', source: 'false' } 结论相反 —— 布尔字面量执行了,同义 envelope 被拦。false 是元数据能写出的最明确的一句「永远不要执行这个」(把动作关掉的模板产出的正是它),所以方向要紧:动作真的跑了,可能落库。

修法

门改问「有没有声明 condition 门」再求值。「没有可求值的条件」取自 core 唯一的谓词归一器 toPredicateInput(把 ''null、空 source envelope、非谓词值一律映射为 undefined),外加它包裹而非折叠的纯空白字符串 —— 即 #3850 对「空谓词」范围的裁决,与 disabled 门已经在用的同一份。门问对了问题,verdict 就不需要自己的布尔分支:evaluateCondition 对布尔实参原样返回(if (typeof condition === 'boolean') return condition),false 于是自然短路成「拦」。

交给求值器的值刻意保持原样而非先归一,理由与 #3848 / PR #3873 相同、符号相反:toPredicateInput 无条件包裹,已经是模板的 '${x}' 变成 '${${x}}',解析失败后原样返回并被强制为恒 true —— 在 disabled 上这拦住一切,在 condition 上它会放行一切。该归一器缺陷是 #3871;新钉子旁的 tripwire 会在它被修好那天转红。

ActionDef.condition 随之放宽为 string | boolean,与门现在兑现的面一致(也与旁边的 disabled 一致)。这不是消费端的宽容别名:布尔本来就通过接口的索引签名在运行时被接受,只是被忽略了。

逐形状新旧对照(变化行只有一行,方向 = 收紧)

condition 变化
false 执行 拦(Action condition not met) ✅ 唯一变化行
true 执行 执行
缺省 执行 执行
裸 CEL 真 user.role == "admin" 执行 执行
裸 CEL 假 user.role == "guest"
{ dialect: 'cel', source: 'true' } 执行 执行
{ dialect: 'cel', source: 'false' }
${…} 模板真 执行 执行
${…} 模板假
'' 执行 执行(理由换成「未声明」)
' ' 纯空白 执行 执行(理由换成「未声明」)
{ dialect: 'cel', source: '' } 执行 执行(理由换成「未声明」)
0 执行 执行
{} 执行 执行

0 这一行与派单预判不同,如实报出:派单预判变化行在 false / 0 / 空形状,实测只有 false 变。0toPredicateInput 下归一为 undefined(不是谓词)→ 无门 → 放行,与本文件在 disabled 上已落地的 catch { isDisabled = false } fail-open 姿态一致,也与 #3850 的「已声明 = 归一后仍有可求值条件」一致。这同时是本 PR 刻意照抄 ActionEngine.getActionsForLocation 模板的一处:那边非谓词分支保留了历史 Boolean(raw) 强制,visible: 0 于是隐藏(对垃圾 fail-CLOSED)。差异已用一条 DOCUMENTED DIVERGENCE 测试钉住,免得下一个读者以为是漏看。

反向验证(方向先写后跑)

预判写在钉子文件头:把门还原成 if (action.condition)、求值不动,应当恰好三个测试转红,且都点名 false —— (1) condition: false 那一行(handler 又跑了);(2) 布尔/envelope 等价性(false 执行而 {cel,'false'} 被拦,正是本单报的分歧);(3)「已声明的布尔确实到达求值器」(真值门下它永远到不了,spy 收不到 false)。纯表格断言(变化行集合、真值 vs 已声明对照)与两条 tripwire 按构造全绿,其余执行行(含 0{}、三种空形状)全绿。

实跑一致:

 × 'condition: false (declared "never exe…' 11ms
 × a declared boolean and its envelope spelling reach the SAME verdict 2ms
 × a declared boolean DOES reach the evaluator, which short-circuits it 1ms
 Test Files  1 failed | 1 passed (2)
      Tests  3 failed | 38 passed (41)

ActionRunner.disabledGate.test.ts 在还原态下全绿 —— 本 PR 对 disabled 门的语义与文案零改动,唯一共享编辑是那个 module-private 判门 helper 改成了中性名(两道门现在问同一个问题,同文件里不该有第二份同问的定义);等 #3850 / #3862 的定义下沉落地后再一并改读 core 的共享定义。

验证

  • pnpm --filter '@object-ui/core^...' build 绿。
  • 消费半径全量:pnpm exec vitest run packages/core/src/actions packages/core/src/evaluator --maxWorkers=2Test Files 26 passed (26) / Tests 617 passed (617)
  • 新钉子单跑:Tests 21 passed (21)
  • 全仓 pnpm exec turbo run type-check --concurrency=278 successful, 78 total
  • eslint 变更两文件:0 error(仅既有 no-explicit-any warning)。
  • 消费半径扫面:全仓无任何 fixture 把动作的 condition 写成布尔/0("condition": false|true|0 零命中),core 之外无消费者读 ActionDef.condition,故类型放宽与本变更均无外溢。
  • 控制字节自查:grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f]' 三个变更文件零命中。

Generated by Claude Code

#3872)

`ActionRunner.execute` —— 引擎的公共执行入口,所有动作面共用 —— 用
`if (action.condition)` 把门开在**真值**上。真值回答不了门要问的那个问题
(「作者到底声明了条件吗」),而在这个键上它答错的方向是**过度放行**:
`condition: false` 落在 `if (false)`,整段被跳过,`evaluateCondition` 根本没被
问过,动作照常执行。基线实测(`origin/main` @ 2937bcf,handler 自计数):

    condition: false             | handler ran: true  | {"success":true}
    condition: {cel,'false'}     | handler ran: false | {"error":"Action condition not met"}

同一句话的两种拼法,结论相反 —— 布尔字面量执行了,同义 envelope 被拦。`false`
是元数据能写出的最明确的一句「永远不要执行这个」(把动作关掉的模板产出的正是
它),所以方向要紧:动作真的跑了,可能落库。这是 #3492 家族的过度放行那一半,
下面一行的 `disabled` 门(#3848 / PR #3873)病在相反方向。

门现在先问「有没有声明 condition 门」再求值。「没有可求值的条件」取自 core 唯一
的谓词归一器 `toPredicateInput`(把 `''`、`null`、空 `source` envelope、非谓词值
一律映射为 `undefined`),外加它包裹而非折叠的纯空白字符串 —— 这就是 #3850 对
「空谓词」范围的裁决,与 `disabled` 门已经在用的同一份。门问对了问题,verdict 就
不需要自己的布尔分支:`evaluateCondition` 对布尔实参原样返回。

## 行为变更面:单向,且只有一行

只有一种形状换了结论 —— 已声明的布尔 `false`,从执行改为拒绝(`{ success: false,
error: 'Action condition not met' }`,这个键本来就用的文案)。其余逐字节不变:
`condition: true`、缺省、真表达式/真 envelope 照样执行;假表达式、假 CEL
envelope、假 `${…}` 模板照样被拦;三种空谓词(`''`、纯空白、空 `source` envelope)
照样执行,只是理由从「`if ('')` 恰好为假」换成了「什么都没声明」;非谓词垃圾
(`0`、`{}`)照样执行 —— 不是谓词的值不该决定动作的命运,这正是本模块在
`disabled` 上已经确立的 fail-open 姿态。因此本改动只可能开始拒绝执行,绝不可能
开始放行,是 #3848 修法的镜像。

`ActionDef.condition` 随之放宽为 `string | boolean`,与门现在兑现的面一致(也与
旁边的 `disabled` 一致)。这不是消费端的宽容别名:布尔本来就通过接口的索引签名
在运行时被接受,只是被忽略了。

交给求值器的值刻意保持**原样**而非先归一,理由与 #3848 相同、符号相反:
`toPredicateInput` 无条件包裹,已经是模板的 `'${x}'` 会变成 `'${${x}}'`,解析失败
后原样返回并被强制为恒 `true` —— 在 `disabled` 上这拦住一切,在 `condition` 上它
会**放行**一切。该归一器缺陷是 #3871;新钉子旁的 tripwire 会在它被修好那天转红。

## 反向验证(方向先写后跑)

把门还原成 `if (action.condition)`、求值不动:预判恰好三个测试转红,且都点名
`false` —— `condition: false` 那一行、布尔/envelope 等价性、以及「已声明的布尔确实
到达求值器」(真值门下它永远到不了)。实跑一致:`Tests 3 failed | 38 passed`,
`disabled` 门的既有钉子全绿,纯表格断言与两条 tripwire 全绿。

Co-authored-by: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 9, 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)
objectui Ignored Ignored Aug 9, 2026 2:33am

Request Review

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.1 KB 350 KB
Entry file index-DEaAKcfP.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.66KB 3.13KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 482.39KB 106.34KB
core (index.js) 2.96KB 1.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 139.61KB 35.99KB
fields (index.js) 230.97KB 56.74KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.53KB 10.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 44.98KB 12.37KB
plugin-charts (index.js) 61.04KB 17.31KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 117.21KB 30.27KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 236.63KB 59.02KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 112.10KB 27.10KB
plugin-gantt (index.js) 162.55KB 39.57KB
plugin-grid (index.js) 187.63KB 49.66KB
plugin-kanban (index.js) 48.30KB 13.28KB
plugin-list (index.js) 105.12KB 25.48KB
plugin-map (index.js) 16.81KB 5.24KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 25.76KB 7.33KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 19.28KB 6.38KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.02KB 0.55KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.71KB 1.34KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghui
yinlianghui marked this pull request as ready for review August 9, 2026 02:41
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit 6719877 Aug 9, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3872-condition-gate-declared branch August 9, 2026 02:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ActionRunner.execute 的 condition 门用真值判定:condition: false(最明确的「永不执行」)照样执行(实测 handler 跑了)

2 participants