现象
L2 action body 里对 ctx.record 的任何赋值都被静默丢弃 —— 已声明字段和未声明字段一样。没有任何诊断,action 照常返回成功。
defineAction({
name: 'close_deal',
objectName: 'crm_deal',
type: 'script',
body: {
language: 'js',
// stage 是 crm_deal 上确实声明了的字段
source: "ctx.record.stage = 'won'; return { ok: true };",
capabilities: ['api.write'],
},
});
调用后 action 返回 { ok: true },记录没有任何变化。
为什么
packages/runtime/src/sandbox/body-runner.ts:
buildActionSandboxContext 交给 body 的是一份普通快照,不是 proxy:
record: unwrapProxyToPlain(actionCtx?.record),
unwrapProxyToPlain 是 Object.fromEntries(Object.entries(v)) —— 一个新对象。
boundActionHandler 只 return result.value。hook 路径上把 VM 内改动写回宿主的 applyMutationsToInput,action 路径根本没调用。
所以写进 VM 里那份快照的东西,连出 VM 的机会都没有。
为什么值得单独记一笔
这是 #4001 那一族("静默 no-op 制造假完成")的形状:作者以为自己改了记录,runtime 说成功,数据没动。
跟 #4271 / #4305 / #4344 处理的"未知列静默不落库"不是同一个缺陷:
- 那个是"写了个不存在的字段,已声明的字段照常落库";
- 这个是"所有写都不落库,包括完全正确的字段名"。
正因如此,#4344(action body 写集 lint)刻意不检查 ctx.record:在那里只报未声明字段,等于暗示已声明字段会落库 —— 反而放大了这个坑。留言在 validate-action-body-writes.ts 头注释里,指回本 issue。
目前的处境
没有任何文档声称 ctx.record 可写 —— ScriptContext 里写的是 "the record loaded by the dispatcher before the action ran"(packages/runtime/src/sandbox/script-runner.ts),读的语义。content/docs/ui/actions.mdx 里的示例也都只读(ctx.recordId || (ctx.record && ctx.record.id))。
所以这不是 declared ≠ enforced —— 没有东西声明它可写。但类型上是裸的 record?: unknown,而同一个 ctx 上 ctx.input 在 hook 里确实是写回去的,作者按类比推断"record 也能写"是完全合理的。
可选方向(需要决策,不是已定方案)
- 只读到底 + 说清楚。
ScriptContext.record 标注只读语义,ActionSchema.body 的文档写明"要落库走 ctx.api.object(...)",再加一条 advisory lint:action body 里出现 ctx.record.<field> = … 就告警(不管字段声没声明),提示改用 ctx.api。成本最低,跟当前实现一致。
- 让它写回。 在
boundActionHandler 里补 record 的 mutation 写回。这需要先想清楚:写回到哪(dispatcher 预取的那份?)、要不要落库、跟 requiresRecord: false 怎么处理、权限走谁的 context。比看上去大,而且 action 的返回值语义(result.value)本来就是显式的。
- 干脆不给。 把
ctx.record 从 action 沙箱上下文里去掉,只留 ctx.recordId,让作者显式 ctx.api.object(...).findOne(...)。破坏性最大。
倾向 1 —— action 的写路径本来就是 ctx.api,ctx.record 是预取的读快照,这个模型是自洽的,缺的只是"说出来 + 在作者时拦一下"。
参考
现象
L2 action body 里对
ctx.record的任何赋值都被静默丢弃 —— 已声明字段和未声明字段一样。没有任何诊断,action 照常返回成功。调用后 action 返回
{ ok: true },记录没有任何变化。为什么
packages/runtime/src/sandbox/body-runner.ts:buildActionSandboxContext交给 body 的是一份普通快照,不是 proxy:unwrapProxyToPlain是Object.fromEntries(Object.entries(v))—— 一个新对象。boundActionHandler只return result.value。hook 路径上把 VM 内改动写回宿主的applyMutationsToInput,action 路径根本没调用。所以写进 VM 里那份快照的东西,连出 VM 的机会都没有。
为什么值得单独记一笔
这是 #4001 那一族("静默 no-op 制造假完成")的形状:作者以为自己改了记录,runtime 说成功,数据没动。
跟 #4271 / #4305 / #4344 处理的"未知列静默不落库"不是同一个缺陷:
正因如此,#4344(action body 写集 lint)刻意不检查
ctx.record:在那里只报未声明字段,等于暗示已声明字段会落库 —— 反而放大了这个坑。留言在validate-action-body-writes.ts头注释里,指回本 issue。目前的处境
没有任何文档声称
ctx.record可写 ——ScriptContext里写的是 "the record loaded by the dispatcher before the action ran"(packages/runtime/src/sandbox/script-runner.ts),读的语义。content/docs/ui/actions.mdx里的示例也都只读(ctx.recordId || (ctx.record && ctx.record.id))。所以这不是 declared ≠ enforced —— 没有东西声明它可写。但类型上是裸的
record?: unknown,而同一个ctx上ctx.input在 hook 里确实是写回去的,作者按类比推断"record 也能写"是完全合理的。可选方向(需要决策,不是已定方案)
ScriptContext.record标注只读语义,ActionSchema.body的文档写明"要落库走ctx.api.object(...)",再加一条 advisory lint:action body 里出现ctx.record.<field> = …就告警(不管字段声没声明),提示改用ctx.api。成本最低,跟当前实现一致。boundActionHandler里补 record 的 mutation 写回。这需要先想清楚:写回到哪(dispatcher 预取的那份?)、要不要落库、跟requiresRecord: false怎么处理、权限走谁的 context。比看上去大,而且 action 的返回值语义(result.value)本来就是显式的。ctx.record从 action 沙箱上下文里去掉,只留ctx.recordId,让作者显式ctx.api.object(...).findOne(...)。破坏性最大。倾向 1 —— action 的写路径本来就是
ctx.api,ctx.record是预取的读快照,这个模型是自洽的,缺的只是"说出来 + 在作者时拦一下"。参考
packages/runtime/src/sandbox/body-runner.ts(buildActionSandboxContext/boundActionHandler/applyMutationsToInput)packages/runtime/src/sandbox/script-runner.ts(ScriptContext.record)