Repository navigation
多行选择从未投递进 action input:REST 无任何合法形状携带 selectedIds,console 工具栏在客户端就拦下多行(17.0.0-rc.2) #5568
Description
Activity
分诊:保留
pm:queue,补domain:cli,范围重框为验证单 —— 过时前提检查发现标题主张与 17.0.0-rc.2 已声明的契约冲突。- 「REST 无任何合法形状携带 selectedIds」与 rc.2 spec 不符:声明的多选投递形状是
params._selectedIds(带下划线的内建键),由列表视图的bulkActions/bulkActionDefs驱动 ——execution: 'aggregate'的 def 一次调用携带整个选择集,渲染器注入、参数门放行:packages/spec/src/ui/action-params.zod.ts:63-70(ACTION_PARAM_BUILTIN_KEYS含_selectedIds)、bulk-action.zod.ts:205、view.zod.ts:1193,均在 rc.2 CHANGELOG 节内(objectui#3139 配套);- liveness 台账
packages/spec/liveness/view.json:155:objectui 侧useBulkExecutor默认 per-record fan-out +ObjectGrid.runBulkActionAggregate的 aggregate 分支(verified objectui@4bf612c)。
- 三条证据测的都是未声明形状:A 顶层
selectedIds;Bparams.selectedIds(无下划线 —— ADR-0104 严格参数门按声明拒绝,该门在正常工作);C 的单行 toast 与 hotcrm#588 摘除自家bulkActions行自洽 —— 多选工具栏本就由视图的bulkActions驱动(action.zod.tsPOST /data/sharing/rules绕过SharingRuleSchema:criteria缺失或拼错静默变成"共享该对象全部记录",与 ADR-0049 "never seeded as a permissive match-all" 直接冲突 #3896 tombstone 亦写明)。 - 重框后的派发范围:在 rc.2 pin 下端到端验证声明路径 —— 视图挂
bulkActionDefs(execution: 'aggregate')→ console 多选 → 一次 POST 携带params._selectedIds→ action handler 从ctx.params._selectedIds读到。绿 → 以证据关单,并在来源单指路声明形状(app 侧恢复bulkActions行、handler 改读params._selectedIds);红 → 按实测落点修(若落 console 渲染器侧,上报改路由repo:objectui)。⛔ 勿另造新的 selectedIds 通道 —— 契约已有一条。
本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
- 「REST 无任何合法形状携带 selectedIds」与 rc.2 spec 不符:声明的多选投递形状是
认领(PM 会话
session_016FNvXhtSdnEGEfLEsMmvxh,cli 车道,v17-rc2 优先,按分诊 19:59Z 重框为验证单派发)- 分支:
claude/issue-5568-selectedids-declared-path(仅在需要修复时使用) - 工作树:
../objectstack-5568(os-dev 自建) - 范围锚定分诊:在 rc.2 pin 下端到端验证声明通道——视图挂
bulkActionDefs(execution: 'aggregate')→ console 多选 → 单次 POST 携带params._selectedIds→ handler 从ctx.params._selectedIds读到。绿 → 以证据关单并在 hotcrm#508 指路声明形状;红 → 按实测落点修,console 渲染器侧则上报改路由 objectui。⛔ 勿另造新通道。
Generated by Claude Code
- 分支:
开工(os-dev,PM 会话
session_016FNvXhtSdnEGEfLEsMmvxh派发,cli 车道)- 工作树:
../objectstack-5568,分支claude/issue-5568-selectedids-declared-path(验证单,仅在实测红且落点在本仓时才提交代码) - 按分诊 19:59Z 重框执行:先证后修。在 rc.2 语义下端到端验证声明通道
params._selectedIds(内建下划线键,packages/spec/src/ui/action-params.zod.ts:63-70),REST 半边为主、console 半边尽力驱动。 - 结论无论绿红都回评完整证据链;不关单,留 PM 复核。
Generated by Claude Code
- 工作树:
结论:绿。前提不成立 —— 声明通道
params._selectedIds在 rc.2 上工作正常,报告的三条证据测的都是未声明形状。不写平台代码。分诊 19:59Z 的重框经真机端到端验证成立。以下是完整证据链。
验证环境
@objectstack/spec@17.0.0-rc.2,worktree 基于origin/main@a7b854f19- 真机:
pnpm dev:crm -- --fresh -p 41973 --seed-admin(全新种子库、独立端口、admin token) - fixture:在 app-crm 上逐字复刻 hotcrm 的形状 ——
crm_opportunity上一个type: 'script'的 action(声明params: [probability],body 读input._selectedIds并逐个 update),列表视图挂bulkActionDefs: [{ name, operation: 'custom', execution: 'aggregate' }]。fixture 为临时探针,验证后已撤销,工作树干净。 - 写入字段选
probability而非stage:opp_stage_transitions是stage上的 state_machine 校验规则,非法跳转会因为与本单无关的理由拒绝写入,会污染证据。probability是普通 percent 字段,写入落地即为无歧义证据。
REST 半边 —— 四种形状同一 action、同一会话对照
基线(两行
probability均为 100):1ByPWM74NPmmpfhx Acme — Support Tier Upgrade probability=100 1PmVs6D5fMin_XT2 Globex — Year-Ago Implementation probability=100A. 顶层
selectedIds(报告形状 A)POST /api/v1/actions/crm_opportunity/crm_mass_update_stage {"selectedIds":["1ByPWM74NPmmpfhx","1PmVs6D5fMin_XT2"],"params":{"probability":42}} → HTTP 400 {"success":false,"error":{"code":"VALIDATION_ERROR","message":"mass_update_stage: no opportunity selected","httpStatus":400}}顶层键不进 params bag,body 自己的「未选中」守卫命中 —— 与报告的 A 条一字不差地复现了。
B.
params.selectedIds,无下划线(报告形状 B){"params":{"selectedIds":[...],"probability":42}} → HTTP 400 {"success":false,"error":{"code":"VALIDATION_ERROR","message":"Invalid action params: Unknown action param \"selectedIds\" — not declared on this action","httpStatus":400}}同样逐字复现。ADR-0104 严格参数门按声明拒绝未声明键,该门在正常工作 ——
selectedIds(无下划线)不是内建键。D. 声明通道
params._selectedIds{"params":{"_selectedIds":["1ByPWM74NPmmpfhx","1PmVs6D5fMin_XT2"],"probability":42}} → HTTP 200 {"success":true,"data":{"ok":true, "seenSelectedIds":["1ByPWM74NPmmpfhx","1PmVs6D5fMin_XT2"], "updated":2, "touched":["1ByPWM74NPmmpfhx","1PmVs6D5fMin_XT2"]}}参数门放行内建键(
_selectedIds与已声明的probability同时在 bag 里,无冲突),handler 读到了两个 id。写入生效 —— re-GET 两行:
{"id":"1ByPWM74NPmmpfhx","probability":42,"updated_at":"2026-08-05T20:40:42.784Z"} {"id":"1PmVs6D5fMin_XT2","probability":42,"updated_at":"2026-08-05T20:40:42.805Z"}100 → 42,两行都写进去了。
「单次调用」的服务端佐证 —— 整段验证服务端只打出 2 条 action-audit 行(A 条到达 handler 后被 body 守卫抛出;B 条在参数门就被拒,未到 handler;D 条 1 条):
[action-audit] REST action 'crm_opportunity/crm_mass_update_stage' — body executes TRUSTED ... [action-audit] REST action 'crm_opportunity/crm_mass_update_stage' — body executes TRUSTED ...即 D 条是一次派发、两行写入,符合 aggregate 契约。
平台侧代码路径(file:line)
packages/spec/src/ui/action-params.zod.ts:70—ACTION_PARAM_BUILTIN_KEYS = ['recordId','objectName','_selectedIds'],validateActionParams的allow集合放行之。packages/runtime/src/domains/actions.ts:291— 参数门enforceActionParams在 handler 前执行;:346—params: { ...reqParams, recordId, objectName },即_selectedIds原样进入ctx.params。packages/runtime/src/sandbox/body-runner.ts:338—input: unwrapProxyToPlain(actionCtx?.params ?? {})。这条值得单独点出:script body 里的input就是 params bag 本身,所以声明键在 body 内的读法是input._selectedIds(等价于ctx.params._selectedIds)。报告里「input.selectedIds在所有已知形状下都为空」——input的确是对的容器,少的只是那个下划线。
console 半边 —— 说明:未浏览器驱动,以 objectui 源码 + 专用回归用例为证
本工作树与共享 checkout 均无
packages/console/dist(启动日志:No console dist … skipping SHA check),objectui 亦未安装依赖,构建 + 浏览器驱动成本过高,故如实说明:console 半边未真机驱动。以下为源码级直接证据(objectui@68b6a28,读取未执行):packages/plugin-grid/src/ObjectGrid.tsx:1979—params: { ...params, _selectedIds: ids },渲染器注入点。packages/plugin-grid/src/hooks/useBulkExecutor.ts:112,261— aggregate 分支走单次 bulkCall。- 报告形状 C 的成因就在这里 ——
packages/core/src/actions/serverActionHandler.ts:169:多选守卫在const isAggregateDispatch = Array.isArray(params._selectedIds); if (recordId == null && !isAggregateDispatch) { … selected.length > 1 → return { error: 'This action runs on a single record — select exactly one row.' };_selectedIds存在时被显式绕过。报告 C 条弹出该 toast,恰恰证明_selectedIds没有被注入 —— 而它没被注入,是因为视图上没有bulkActions/bulkActionDefs声明(hotcrm#588 已把那一行摘除),按钮退化成普通单记录 action,于是被这个本就为它设计的守卫拦在客户端。零网络请求是正确行为,不是通道缺失。 - 两条专用回归用例已钉死该形状:
useConsoleActionRuntime.test.tsx:958(aggregate 派发通过多选守卫且不带 recordId)、objectBulkActionDispatch.test.tsx:239(「issues exactly ONE request carrying every selected id under_selectedIds」)。 - liveness 台账
packages/spec/liveness/view.jsonbulkActionDefs行:status: live,Verified objectui@4bf612c。
因果链复盘
三条证据可由同一个原因完全解释,且都不是平台缺陷:
- hotcrm#588 摘除
opportunity.view.ts的 bulk 声明行 → - 渲染器无 aggregate def,不注入
params._selectedIds→ - 工具栏按钮成为单记录 action,多选被客户端守卫拦下(证据 C) →
- 转而手工试 REST,试的是顶层
selectedIds(证据 A)与无下划线params.selectedIds(证据 B),二者都不是声明形状 → 两个正确的 400。
hotcrm#508 指路(三处改动,均在 app 侧)
- 视图:
opportunity.view.ts恢复 bulk 声明。整个选择集一次送达,用 aggregate def:注意bulkActionDefs: [ { name: 'mass_update_stage', operation: 'custom', execution: 'aggregate' }, ]
operation: 'custom'若不带execution: 'aggregate',parse 阶段即被拒([spec] bulkActionDefs is z.record(z.any()) — type the def shape (ADR-0018 second-vocabulary smell) and cover it in reference-integrity lint #4457)—— 那种形状历史上是「N 个绿勾、零工作」的静默 no-op。若想要的是逐行派发,则改用bulkActions: ['mass_update_stage']裸字符串形式。 - handler:改读
ctx.params._selectedIds(script body 内即input._selectedIds),带前导下划线;不要试图把它声明进 action 的params[],它是渲染器注入的内建键,声明不了也不需要声明。 - REST 调用(如脚本/集成侧直接打):
{"params":{"_selectedIds":[id1,id2], …}},不要用顶层selectedIds,也不要用无下划线的params.selectedIds。
另注:aggregate 结果是 all-or-nothing —— handler 覆盖不了整个选择集时应当整体拒绝,没有逐行重试。
界外发现
- 参数门拒绝「差一个下划线」的内建键时无提示:
selectedIds报 Unknown param,不指向_selectedIds(#5568 验证副产品) #5622(finding标签,未挂pm:queue):参数门拒绝「仅差一个前导下划线」的内建键时不给指路提示。本单报告者正是卡在 B 条那句Unknown action param "selectedIds"上才判定通道不存在 —— 一条 near-miss 提示本可在第一次调用就终结此单。纯消息层建议,判定逻辑不变,不构成新通道。
处置
无平台代码改动,无 PR,工作树已撤销 fixture 并保持干净,dev server(:41973)已拆除。按派发约定不关单,留 PM 复核后关闭。
Generated by Claude Code
验证结论:声明通道在 17.0.0-rc.2 上完全工作,标题主张不成立,关单(PM 会话
session_016FNvXhtSdnEGEfLEsMmvxh,cli 车道复核)真机端到端(app-crm 逐字复刻 hotcrm
mass_update_stage形状,crm_opportunity列表视图挂bulkActionDefs+execution:'aggregate'):形状 结果 A 顶层 selectedIds(报告用)400 VALIDATION_ERROR——逐字复现报告,未声明形状,正确拒绝B params.selectedIds无下划线(报告用)400 Unknown action param——ADR-0104 参数门正常工作D params._selectedIds(声明通道)200,handler 读到两个 id,两行 probability100→42 真实写入,服务端 action-audit 仅 1 条 = 单次派发双行写形状 C(console 单行 toast)成因同样定位:objectui
serverActionHandler.ts:169的多选守卫在_selectedIds存在时显式绕过——toast 弹出恰证明_selectedIds未被注入,而未注入是因为 hotcrm#588 摘除了视图的 bulk 声明行。三条证据一个 app 侧原因,均非平台缺陷。给 hotcrm#508 的指路(最有用的一行:script body 里
input就是 params 袋,body-runner.ts:338,所以原写法只差一个下划线):opportunity.view.ts恢复bulkActions行并挂bulkActionDefs(execution: 'aggregate');- handler 读
input._selectedIds; - REST 直调用
params: { _selectedIds: [...] }。
如实说明:console 半边未浏览器实跑(objectui 未安装/未构建,成本高),以源码锚点(objectui@68b6a28
ObjectGrid.tsx:1979注入 /useBulkExecutor.ts:112,261单次调用)+ objectui 两条专用回归用例 + liveness 台账view.jsonstatus:live为证。若需执行级验证,另立单拍成本。界外产出 #5622(交分诊):参数门拒绝「只差一个前导下划线」的内建键时不提示
_selectedIds存在——报告者正是卡在这条消息上才判定通道不存在,纯消息层 DX 改进。平台侧无事可修,关单(works as declared)。感谢报告——它顺带暴露了 #5622 这个真实的指路缺口。
Generated by Claude Code
- added a commit that references this issue
on Aug 10, 2026 - added a commit that references this issue
on Oct 7, 2026
Part of objectstack-ai/hotcrm#508
现象
一个在列表视图上按 bulk 语义接线的 script action(hotcrm 的
mass_update_stage,opportunity 列表),在 17.0.0-rc.2 上不存在任何能把多行选择送达 action input 的派发路径。input.selectedIds在所有已知形状下都为空。证据(hotcrm@0899b4f + @objectstack/* 17.0.0-rc.2,独立 dev server、全新种子库、admin token)
每次尝试后 re-GET 记录:
stage保持不变。失败均为显式 400 / 显式 toast,rc.0 时代的假成功路径已不存在(那半已修,好)。对照组:单行
{"recordId":…,"params":{…}}派发能到达服务器并执行到 action body(hotcrm 侧 body 内另有一个与本单无关的签名缺陷,hotcrm 自修)。即 recordId 通道是通的,缺的只是多行选择这一通道。期望
以 bulk 语义接线的 action 至少有一条有文档的派发形状能把当前选择集送达 action input(例如
input.selectedIds):REST 一条 + console 选择栏/工具栏一条。关联
opportunity.view.ts的 bulkActions 一行并以「选择栏 POST 携带 selectedIds」为验收条件(记录在 hotcrm#508)。