Repository navigation
A designer configSchema and the keys its executor actually reads are still unreconciled — notify honours cfg.source, which no schema declares #4045
Description
Activity
分析两处决策。都先取证再下结论,下面每条主张都有可复现的依据。
决策一:
notify.source—— 进转换层,不进configSchema代码自己已经回答了。
notify-node.ts:80-85:Accepts the flat
sourceObject/sourceIdkeys (canonical — mirrors thesys_notification.source_object/source_idcolumns) or the nestedsource: { object, id }form (mirrors the messagingemit()surface).所以不是两个平等形状:作者已把
sourceObject/sourceId定为 canonical,source是被一句裸??容忍的第二形状。Prime Directive #12 对这个形状有明确处方 —— "never a bare??, and no new executor shims",改为转换层入口,使其 declared / loud / tested / removable on a schedule。而入口已经存在:
conversions/registry.ts:979的flow-node-notify-config-aliases,#3796 已迁入to→recipients、subject→title、body→message、url→actionUrl。source是同一执行器上唯一漏下的。判据是哪个方向减少契约数量:加进
configSchema等于平台永久承认两种写法(设计器要么只提供一种、schema 就在撒谎,要么提供两种、让作者在 canonical 与非 canonical 间选);进转换层则载入时归一,执行器只读一种,裸??可删,且这条 alias 有排期可退役。实现上一个必须说清的差异:现有四条是
pairs形式的 1:1 改名,而source: {object,id}→sourceObject+sourceId是 1 拆 2 的解构,套不进现成的pairs机制,需要在该 conversion 里写一小段自定义变换。工作量仍小,但不是往数组里加一行。notify-node.test.ts:136确实测了嵌套形状;退役时那条测试要从「执行器读得懂它」改成「转换层把它归一了」—— 这正是转换层优于??的地方:测试点从执行器内部挪到一个有名字、可查询的注册表。决策二:对账方案 —— 建议第 3 条(执行器用 Zod 解析,
configSchema由它生成)理由 1:前两条方案没有解决 #3528 的真实成本
三个方案保护的不是同一批人:
方案 抓平台开发者的漂移 抓 app 作者的写错 1 手维护 read-key ledger ✅ ❌ 2 静态分析执行器 ✅ ❌ 3 执行器用 Zod 解析 ✅ ✅ #3528 的实际代价是一个 app 作者写错方言。方案 1/2 对这条路径毫无作用 —— 它们只保证「声明的属性有人读」,不保证「作者写的键是声明过的」。
在合并了 #4040 的 main 上实测,这个洞仍然开着:
{ name: 'a', required: true, visibleIf: 'b == true' }, // visibleWhen 的错拼 hideWhen: 'nope', submitLabel: 'Go', totallyMadeUp: 42, // 纯臆造
registerFlow完全不报错,注册成功。选 1 或 2,这段明天依然静默通过。理由 2:Prime Directive #1 已经规定了,现状是违规
Zod First. All schemas start as Zod. JSON Schemas generated from Zod.
而 builtin 的
configSchema是手写 JSON Schema 字面量(screen-nodes.ts:50)。这不是"加一个新 ratchet",而是把 builtin node config 收回既有规则。它已经半建好:
control-flow.zod.ts里LoopConfigSchema/ParallelConfigSchema/TryCatchConfigSchema都是真 Zod,而 descriptor 又手抄了一份 —— AGENTS.md 甚至白纸黑字承认这次重复("the shippedloopdescriptor carries the same marker on its hand-written configSchema literal")。对这三个节点,方案 3 主要是删掉手抄的那份。理由 3:防 AI 写错,闭合 schema 的收益远高于对人
z.record(z.unknown())是对 AI 作者最不利的形状。LLM 的典型错误不是语法错,而是貌似合理的键名/方言:visibleIf而非visibleWhen、{createOpportunity}而非createOpportunity、fieldValues而非fields(#2419)。共同点是读起来完全正确。人类作者有一道兜底 —— 他会打开浏览器点一下。AI 没有:它会自信交付,错误在运行时以「Submit 没反应」出现。#3528 的时间线就是这个模式,还额外烧掉两次误诊。
所以收益不是线性的:未知键立即报错,等于把「静默死路」变成「一次可自愈的错误」—— Zod 报错点名键与期望形状,agent 一轮就能改对;现在的反馈是没有反馈,agent 无从下手。错误信息就是 AI 的反馈回路,这比给人看的报错更重要。反过来说,方案 1/2 的产出是给平台 CI 看的,app 作者的 agent 永远看不到。
另外方案 3 是三条里唯一顺带拿到类型 /
required/ 未知键运行时校验的 —— 因为执行器要真的parse,而不只发布一份给设计器看的表单描述(这正是 #4040 更正的那句虚假声明所指)。方案 3 的可行性:已实测,5/6 通过,1 个已知障碍
承重假设是「Zod 能否无损重现现有 configSchema」。用镜像
screen形状的 Zod 跑z.toJSONSchema({target:'draft-2020-12', io:'input', unrepresentable:'any'}):形状 结果 xExpression标记嵌套在数组项属性上(fields[].visibleWhen)✅ 保留 xRef: { kind: 'object' }对象值标记✅ 保留 format: 'multiline'✅ 保留 enum+default✅ 保留 数组项类型( items.properties.required.type)✅ 保留 keyValue 自由映射 ❌ z.record()产出additionalProperties: {},不是true顶层标记本来就有测试覆盖(
control-flow.test.ts:61);上面前五条是此前未验证的部分,都成立。唯一障碍及其定性 —— 比看上去轻:
.meta({ additionalProperties: true })可以覆盖回true(实测通过)。- 而且设计器本来就不在意:
objectui的json-schema-to-fields.ts:456判的是additionalProperties !== undefined && !== false,所以{}照样渲染成 keyValue 编辑器。 - 真正会红的是平台自己的
config-schemas.test.ts,它断言严格toBe(true)。也就是说这条测试比它要保护的消费者更严 —— 本身就是一处轻微过度约束,值得顺手放宽成与 objectui 实际规则一致。 - 受影响范围已精确统计:7 个槽位 ——
assignment.assignments、get_record.filter、create_record.fields、update_record.filter、update_record.fields、delete_record.filter、screen.defaults。
结论:方案 3 没有被否掉,但迁移每一个 keyValue 槽位时必须显式处理这一项,别指望默认输出对。
不建议方案 2(静态分析)
单独说明,因为它看着最省事。
cfg.X字面读取好抓,但readAliasedConfig、解构、cfg[key]动态取值,以及把整个cfg传进辅助函数的写法都抓不到 —— 而notify恰好就是这种(resolveSource(cfg, ...))。也就是说,静态分析对我们唯一已知的漂移实例都会漏报。一个会漏掉现有唯一样本的 ratchet 比没有更糟:它制造"已覆盖"的错觉。建议的落地顺序
不要一次性动 12 个执行器。
notify.source进转换层(决策一)—— 独立、小、有现成入口,先清掉裸??。- 拿
screen验证方案 3 的形状 —— 它是 Console: screen-flow Submit never calls the resume endpoint — every screen flow is un-completable from the UI #3528 现场,字段最复杂(扁平列表 + object-form + repeater),能把生成路径上的坑一次暴露完。loop看着更简单,但它的 Zod 已存在,验证不到"新写一份 Zod"的成本。 - 未知键先 warn 不 fail。 迁移期必然有存量 flow 带未声明的键(4 个执行器里已发现
notify.source一处,其余 8 个节点未查)。先 warn 一个 release 收集实际规模,再收紧成 error —— 否则第一个升级的客户就是registerFlow全面爆炸。 loop/parallel/try_catch收尾 —— 纯删重复,最省力,放最后。
Generated by Claude Code
步骤 B 实测结果 —— 区域型节点不能走「单一 Zod 源」
#4064 里那张对比表是读源码推的。现在跑了真实生成,在 main
53fbf49上,选项与control-flow.test.ts一致(target: 'draft-2020-12',io: 'input',unrepresentable: 'any')。三种可能结局中,是第三种
我之前列出的三种可能:
$defs+$ref形式、循环引用直接报错、或全内联。结果是全内联:今天发布的(手写) 从 Zod 生成的 倍数 loop597 字符 · 深度 5 · 25 键 5,537 · 深度 14 · 201 键 9.3× / +9 层 / 8× parallel294 字符 · 深度 6 · 16 键 5,112 · 深度 15 · 189 键 17.4× / +9 层 / 12× try_catch737 字符 · 深度 5 · 40 键 10,739 · 深度 14 · 397 键 14.6× / +9 层 / 10× 三个都生成成功、没有抛错,但
$defs为 false、全文不含"$ref"—— 意味着FlowNodeSchema是逐处完整内联的,没有任何 ref 折叠可以指望。内联进去的是什么
loop.body.properties.nodes.items变成了完整的 FlowNodeSchema:"body": { "type": "object", "properties": { "nodes": { "minItems": 1, "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string", "description": "Node unique ID" }, "type": { "type": "string", "minLength": 1, "description": "Action type — a built-in FlowNodeAction id or a plugin-registered node type. Validated against the live action registry at registerFlow() (ADR-0018)…" }, "label": { "type": "string", "description": "Node label" }, "config": { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {} }, "connectorConfig": { … }
而今天发布的是
nodes: { type: 'array' }—— 没有items。设计器据此把子图当不透明处理(它在画布上编辑)。顺带确认了两处更小的预测,都成立:
collection多出minLength: 1,iteratorVariable多出default: "item"和minLength: 1。另外还多出手写版从未有过的propertyNames,以及嵌套层里再次出现的additionalProperties: {}(就是之前测到的z.record()那个问题)。结论:C 阶段应当分流,而不是统一方案
区域型节点(
loop/parallel/try_catch)→ 选项 1(对账 ratchet),不要合并源。理由是实测数字而非偏好:要让生成结果可用,必须写一个投影把 region 键裁回不透明 —— 那等于丢掉生成结果的九成(201 → 25 键)。而一旦引入投影,「单一真相源」的收益就是假的:你要维护 Zod、维护投影规则、再验证投影结果,三样东西,比现在两样更多。
两份产物职责不同:手写字面量是表单描述(刻意浅、刻意松),Zod 是校验契约。它们合法地应该不一样。这种情况下要的是「差异被声明且被守住」,而不是「差异消失」——#4064 已经把守住那一半做完了。
扁平节点(
screen/ CRUD /http/notify)→ 选项 3 仍然可行。 它们没有 region 键,我早前的探针已验证screen需要的形状全部无损(数组项内的xExpression、对象值xRef、format、enum+default),唯一要显式处理的是 keyValue 槽位的additionalProperties(7 个槽位,.meta({ additionalProperties: true })可覆盖)。修正记录
#4045 原文、以及我自己上一条评论,都把这三个节点判为「纯删重复、最省力,放最后」。这是错的,B 的数据是反证。#4064 的提交信息里已经记了这次更正。
Generated by Claude Code
- added a commit that references this issue
on Jul 30, 2026 阶段小结 —— 已完成的部分、被推翻的前提、以及剩下什么
这个 issue 上现在有三条我的评论(原始分析、B 的实测数据、以及被 B 推翻的前提)。这条把结论固定下来,免得下一个接手的人(很可能是另一个 agent)重走一遍。
已完成
PR 内容 #4050 notify.source进 ADR-0087 转换层,执行器那句裸??删除#4059 registerFlow对未声明的 config 键发警告,带路径与已声明键清单#4064 钉住 loop/parallel/try_catch的表单形状(此前零断言)#4078 表单 ↔ Zod 双向键集对账 + DELIBERATELY_SHALLOWledger加上先行的 #4040(表达式 ledger + ratchet),
visibleWhen那一类已经关闭。⚠️ 本 issue 开篇的一个前提是错的原文(以及我自己的第一条评论)把
loop/parallel/try_catch判为「冗余副本,等着被单一 Zod 源去重,纯删除,最省力,放最后」。实测推翻了它。 手写字面量不是 Zod 的副本,而是职责不同的第二份产物:表单描述(刻意浅、刻意松)vs 校验契约。生成结果 9–17× 大、深 9 层、全内联无
$ref(loop597→5,537 字符 / 25→201 键)。要可用就得写投影裁掉九成,那时「单一真相源」是假的 —— Zod + 投影规则 + 投影校验 = 三样,比现在两样更多。所以区域型节点走的是选项 1(对账 ratchet),不是选项 3。 已在 #4078 落地。
剩下的:C 的扁平侧
screen/ CRUD /http/notify没有 region 键,选项 3 仍然可行。已测清的障碍清单:- 7 个 keyValue 槽位(
assignment.assignments、get_record.filter、create_record.fields、update_record.filter、update_record.fields、delete_record.filter、screen.defaults)——z.record()产出additionalProperties: {}而非契约要求的true。.meta({ additionalProperties: true })可覆盖(已验证)。 - objectui 其实不在意:
json-schema-to-fields.ts:456判的是!== undefined && !== false,{}照样渲染 keyValue。真正会红的是平台自己的config-schemas.test.ts(严格toBe(true))—— 它比它保护的消费者更严,顺手放宽即可。 screen需要的其余形状全部无损:数组项内的xExpression、对象值xRef、format、enum+default、数组项类型(已实测 5/6 通过,第 6 就是上面那条)。
一条前置建议:扁平侧动的是
screen—— #3528 的现场,且它的configSchema直接决定 Studio 里 screen flow 的编辑体验。而 objectui 的渲染没有跨仓库契约测试守着。所以建议配合浏览器验证(仓库有dogfood-verificationskill 正为此准备),而不是纯靠单测推上去。剩下的:3b(收紧成 error)
必须等 #4059 的警告在真实 app 上跑过一个 release。原因写在
validateNodeConfigKeys的 TSDoc 里:未声明键有三类(作者拼错 / 执行器在读但没声明 / 死配置),而 13 个带 schema 的 builtin 里只审过 4 个。直接 fail 是拿剩下 9 个的未知规模去赌 ——notify.source就是第二类,靠「把整个cfg传进辅助函数」隐藏,最难静态发现。收紧时报错应带处方,照
object.zod.ts:1243的UNKNOWN_KEY_GUIDANCE模式立墓碑。顺带修掉的一个信噪比问题
#4064 / #4078 都是纯测试改动,却各点亮 6 个文档(连续三次)。已在 #4091 修掉 —— 纯测试 diff 在原理上不可能让实现准确性文档失效。
Generated by Claude Code
- 7 个 keyValue 槽位(
收尾 —— C 扁平侧建议不要单独做,并入 3b
上一条留了「C 扁平侧仍可行」,我去把它落地前先取了证,结论变了。记在这里,理由和数据都在。
两个事实凑在一起否掉了「单独换源」
1.
screen今天没有 Zod。$ grep -rn "ScreenConfig|ScreenNodeConfig" packages/spec/src --include=*.ts # → 空 $ grep -n "export const \(Loop\|Parallel\|TryCatch\)ConfigSchema" packages/spec/src/automation/control-flow.zod.ts 101: LoopConfigSchema 152: ParallelConfigSchema 185: TryCatchConfigSchema区域三节点确实有两份产物 —— 那里「重复」是真的。
screen只有手写字面量,所以选项 3 在这里不是去重,是新造一份第二产物,而它唯一的消费者就是生成器本身。那是增加间接层,不是减少。2. 逐字节复现需要一个投影。 从跑起来的 server 抓下的真实 wire 产物(
GET /api/v1/automation/actions,18 个 descriptor)里,screen的configSchema是:- 没有
$schema—— 而z.toJSONSchema一定会加 defaults没有propertyNames—— 而z.record()一定会加- 任何一层都没有
required
所以要么接受设计器输入发生变化,要么写后处理剥掉这些。而**「一旦需要投影,单一真相源就是假的」正是我否掉区域侧的同一条理由** —— 这里投影只有 2 处而非九成,程度差很多,但性质相同:要维护 Zod、投影规则、投影校验。
净账:付出「一份 Zod + 每个属性的
.meta()+ 一个 normalizer」,换来「少一份手写字面量」,可读性还大概率变差(title/description 现在内联在字面量里,一眼可读)。真正的奖品在别处,而它属于 3b
$ grep -n "parse\|safeParse" packages/services/service-automation/src/builtin/screen-nodes.ts # → 空screen执行器完全不校验 config,直接读cfg.*。所以选项 3 的价值从来不是「换生成源」,而是让执行器用那份 Zod 去parse—— 那才拿到configSchema现在根本没有的类型 /required/ 未知键运行时校验(#4040 更正的那句虚假声明所指的正是这个)。但那是大得多的改动、有真实行为风险:今天能加载的 screen config 可能开始 parse 失败。而它需要的前置条件和 3b 完全相同 —— 得先知道存量 metadata 里未声明键的真实分布,而这正是 #4059 的警告在收集的东西。
建议:把 C 扁平侧并入 3b。 等 #4059 跑过一个 release,一次性把「Zod 化 + 执行器 parse + 未知键收紧」作为一个有数据支撑的决定做掉,而不是现在先单独换源再回头改一遍。
一个副产品:更强的验证判据
原计划是用浏览器截图比对设计器表单。实际发现有个更强的判据:如果发布出去的
configSchemaJSON 逐字节相同,设计器表单就不可能变 —— 那份 JSON 就是设计器的全部输入。所以证明只需一次 JSON diff,浏览器和 objectui 都不必进场,而且它枚举了每一个属性,比一张截图(只覆盖一个视口)更完整。做步骤 2 时可以直接用这个。本条线已落地的部分
PR 内容 #4040 visibleWhen上 wire + 表达式 ledger + ratchet#4050 notify.source进转换层,裸??删除#4059 未声明 config 键发警告(含 did-you-mean 与已声明键清单) #4064 钉住控制流三节点的设计器表单形状(此前零断言) #4078 表单 ↔ Zod 双向键集对账 + DELIBERATELY_SHALLOWledger剩下的两项(C 扁平侧、3b)现在是同一项,且都在等同一份数据。
Generated by Claude Code
- 没有
测量更新:§2 的三个选项可以定下来了,外加 caveat 清单里第一个被确认的漂移
本条不提新方案,只把 §2 里当时还只能定性描述的东西量成事实。
1. 选项 (b)「静态分析
cfg.」不是 fragile,是不成立原文写的是 "fragile against indirection"。实测下来它在这里不是精度问题,而是根本不适用:
// packages/services/service-automation/src/builtin/logic-nodes.ts:91-92 // No `assignments` wrapper — top-level config keys ARE the variables. for (const [k, v] of Object.entries(config)) pairs.push([k, v]);
这个节点的顶层 config 键就是作者自选的变量名,没有固定键集。而
crud-nodes.ts有 5 个additionalProperties: true的 keyValue 区域。一个cfg.X提取器在这两处会把几乎所有已声明键报成「声明了但没人读」—— 全部是假阳性。builtin additionalProperties: truexExpressionconnector-nodes 0 0 crud-nodes 5 0 http-nodes 0 0 logic-nodes 1(且顶层键即变量名) 0 map-node 0 1 notify-node 0 0 screen-nodes 1 2 要做到可靠,提取器就得把
logic/crud整体标成「不可分析」入账 —— 大量机械换极少覆盖。建议正式划掉 (b)。2. 选项 (c) 的两半可以分开做 —— 因为「执行器 parse」今天在任何节点上都不存在
两条互相独立的测量:
这 7 个节点类型没有任何 config Zod。 在整个
packages/spec/src里搜节点名 +Config*Schema,4 个命中全是无关的(ConnectorAuthConfigSchema连接器鉴权、CrudEndpointsConfigSchemaREST server、HttpServerConfigSchema系统 HTTP server、HttpDestinationConfigSchema日志目的地)。且zod在整个service-automation/src里一次都没被 import。更关键:已有 Zod 的那四个,运行时也不用。 所有 builtin 里唯一的
.parse(是wait-node.ts:160的Date.parse(wakeAt)。而LoopConfigSchema/ParallelConfigSchema/TryCatchConfigSchema/WaitExecutorConfigSchema的源码消费者为零 —— 每一处命中都在packages/spec/dist/**的.d.ts重导出里。它们今天是纯类型导出。这改变了 (c) 的成本结构。原文把 (c) 描述成 "most invasive",前提是「补 Zod」和「让执行器 parse」必须一起做。实测表明:
- 「补 Zod」这半是廉价的,而且与现状一致 —— 已有四个 Zod 就是这么存在的(有 schema、不 parse)。非 strict、不接
parse,则运行时行为零变化、存量 app 零破坏风险,同时立刻获得表单 ↔ Zod 的精确对账(两个键集比对,无启发式)。 - 「让执行器 parse」这半是一个平台级的单一决定,不是逐节点的工作 —— 因为今天没有任何节点在 parse。而它正是需要 feat(automation,formula): warn on undeclared flow-node config keys (#4045) #4059 跑一个 release 的分布数据的那一半。
⚠️ 一条纪律,写下来以免被做歪:这份 Zod 必须从读执行器写出来,绝不能照着configSchema抄。 抄出来的 Zod 让对账变成同义反复 —— 由构造保证通过,什么也证明不了。两侧独立成文才是漂移暴露的机制。(这与我在上一条评论里否掉「从 Zod 生成表单」不矛盾:那里否的是把两份合成一个生成物;这里要的恰恰是保留两份独立陈述并强制它们一致。)
3. caveat 清单里第一个被确认的漂移:
wait原文 Scope caveat 点名未查
wait。查了,是漂的,而且是notify.source的同一形状:wait注册了ActionDescriptor(name / description / icon / category / supportsPause / isAsync),但完全没有configSchema—— 设计器渲染得出这个节点,却无法配置它的eventType、时长、信号名。同时执行器从两个不同位置读配置:// packages/services/service-automation/src/builtin/wait-node.ts:53-56 // Prefer the spec-structured `waitEventConfig` block; fall back to a loose // `config` for hand-authored flows that put the same keys under config. const eventType = String(wec.eventType ?? loose.eventType ?? 'timer');
wec.eventType ?? loose.eventType就是本 issue 第 3 点针对cfg.sourceObject ?? src?.object说的那类消费方兜底(PD #12),只不过这次跨的是两个 config 位置。notify.source已按 #4050 毕业进 ADR-0087 D2 转换层;这一处需要同样的判断。另外 spec 里那个未被使用的WaitExecutorConfigSchema大概描述的就是waitEventConfig形状 —— 三份陈述,零对账。4. caveat 清单可以收窄
loop/parallel/try_catch已覆盖:test(automation): pin the control-flow trio's designer forms (#4045) #4064 钉住设计器表单,test(automation): reconcile the control-flow designer forms against their Zod (#4045) #4078 做表单 ↔ Zod 双向键集对账并带DELIBERATELY_SHALLOW账本- 表达式槽已在每个 builtin 上双向对账完毕(fix(automation,lint,spec): validate the expression slots a node's
configSchemadeclares (#4027) #4040 的config-expression-ledger.test.ts走遍所有已注册 builtin),本 issue 关心的是其余键
余下真实规模:3 个完全封闭(notify / http / connector)+ 2 个基本封闭(map / screen)+ 1 个部分(crud,5 个 keyValue 区域入账为故意开放)+ 1 个不可对账(logic,顶层键即变量名,须入账并写明理由)。不是「7 个」。
5. 一条本 issue 成文后才出现的跨族证据
#3786 / #4120 在另一个手写表单族上跑了同一套对账:
METADATA_FORM_REGISTRY的 17 个手写defineForm布局,其中 4 个已经静默漂了 ——object.capabilities实为enable(7 个开关存了个空)、内联列网格提供 16 个FieldSchema从未声明的键、report渲染着 #3496 已剪掉的aria/performance、body.memoryMb与interfaceConfig.sort不可授权。根因与本 issue 完全同一条:schema 不是
.strict(),未声明的键解析通过、然后在写库路上被静默剥掉。它采用的账本不对称性也一样 —— form-only 一律是缺陷不可入账,zod-only 可带理由入账。这是本 issue 值得优先做的经验依据:同一形状在这个仓库的手写表单上命中率约 4/17,且命中的都是用户可见的失效,不是假想的未来漂移。
建议的切法
先只做 notify / http / connector 这 3 个完全封闭的,外加
wait那个判断。理由是用最干净的样本先验证这套技术真的抓到东西 —— 如果 3 个干净样本一个问题都没有,那 4/17 的命中率未必迁移得过来,crud / screen 的 keyValue 边界工作就不值得花。这个判断想用数据做,不想先押注。
Generated by Claude Code
- 「补 Zod」这半是廉价的,而且与现状一致 —— 已有四个 Zod 就是这么存在的(有 schema、不 parse)。非 strict、不接
更正上一条的第 3 条 ——
wait的一半我说错了,而且更正后的版本对方法论有约束说错的部分
我写「
wait没有configSchema,所以设计器配不了eventType/时长/信号名」。这是错的。wait的配置不在config里,而在FlowNodeSchema上一个已声明的兄弟字段waitEventConfig:// packages/spec/src/automation/flow.zod.ts:199-211 waitEventConfig: z.object({ eventType: z.enum(['timer', 'signal', 'webhook', 'manual', 'condition']) .describe('What kind of event resumes the execution'), timerDuration: z.string().optional().describe('ISO 8601 duration (e.g., "PT1H") …'), signalName: z.string().optional().describe('Named signal or webhook event to wait for'), timeoutMs: z.number().int().min(0).optional(), onTimeout: z.enum(['fail', 'continue']).default('fail'), }).optional(),
它每个属性都有
.describe()、在可授权字段清单里(flow.zod.ts:127)、进了生成参考文档(references/automation/flow.mdx:107)、showcase 也确实这么写(flows/index.ts:554)。所以wait的描述符不带configSchema是设计如此,不是漂移。我关于「Studio 配不了」的断言没有依据 —— 而且从这里也无法核实,packages/console是 vendored dist。站得住的部分
// wait-node.ts:53-56 // …fall back to a loose `config` for hand-authored flows that put the same keys under config. const eventType = String(wec.eventType ?? loose.eventType ?? 'timer');
node.config.eventType是一条未声明的第二契约,紧挨着已声明的node.waitEventConfig.eventType。注释自己就说这是给手写流程的兼容。这就是本 issue 第 3 点针对cfg.sourceObject ?? src?.object说的 PD #12 形状,该按 #4050 的方式毕业进 ADR-0087 D2 转换层,或者直接删掉。仓库内没有任何流程通过node.config授权 wait 配置。更正后的发现其实更干净:它是一个纯粹的消费方兜底待毕业,不是缺表单。
对本 issue 提出的做法的约束
wait顺带证明了一件方法论上要紧的事:configSchema不是节点配置契约的唯一存放处。 至少一个内置节点把契约放在FlowNodeSchema的声明兄弟字段上。任何建立在「
configSchema↔ 执行器读了什么」之上的对账,都必须把这种情况算进去,否则会把wait这类节点报成「读了没人声明的键」—— 而那些键其实在别处声明得很完整。这是一个假阳性源,而且是只有逐个读执行器才会发现的那种。前一条评论里那条纪律(Zod 必须从读执行器写出来)现在要再加一句:读执行器时必须同时确认它读的是node.config还是某个声明的兄弟字段。
Generated by Claude Code
本体落地:notify / http / connector 对账 → #4210
按上面「建议的切法」做完了 3 个完全封闭节点(
wait的判断已由 #4161 先行落地)。结果验证了那条 4/17 经验的迁移性:3 个干净样本里 2 个干净、1 个中招,且中招的是用户可见失效。对上的:notify / http
NotifyConfigSchema(11 键)/HttpConfigSchema(7 键)从读执行器写出(没有照configSchema抄——包括channels不过interpolate、method按模式取默认这类只有读执行器才知道的语义,都进了 TSDoc),io-node-form-zod-ledger.test.ts做双向键集对账:两个节点完全一致,零 shallow 条目。纯契约导出,不接parse(那是 3b,等 #4059 的数据)。中招的:connector_action —— 比「declared but never read」更糟一档
执行器只读声明的兄弟块
connectorConfig(上一条评论已确认这是合法形态),但描述符发布着一份把三元组声明成 config 键的configSchema。关键在消费端:objectuiFlowNodeInspector是serverFields ?? fieldsForNodeType(...)——发布了 schema 就整表替换手写表单,且生成字段全部根在['config', key]。所以连真实后端时,Studio 的 connector 表单把connectorId/actionId/input写进没人读的位置,还顺带丢掉 connector/action 选择器。在线创作的节点必然拒绝执行。不是「作者可能写错」,是表单替作者写错。处置照 #4161 的模板:descriptor 停发 schema(入 deliberately-schemaless 类,测试钉死);
flow-node-connector-config-lift抬升存量(带完备性守卫,抬不齐connectorId+actionId就整节点不动,不把步骤拒绝变加载失败);顺带修掉块内第三处 declared ≠ read——input在 spec 里 required 而执行器?? {}、设计器空映射直接省略,已改 optional。方法论上值得记的一条
wait那条更正说「configSchema 不是唯一存放处,逐个读执行器才能发现」——connector 把它推进一步:发布一份根错位置的 configSchema 比不发布更糟,因为设计器把它当真。schemaless 不是缺口,是这类节点唯一正确的发布姿态。剩余
- caveat 清单再收窄:map / screen(基本封闭)、crud(5 个 keyValue 区域待入账)、logic(assignment)(顶层键即变量名,须带理由入账)
- 3b(Zod 化接 parse + 未知键收紧)不变,等 feat(automation,formula): warn on undeclared flow-node config keys (#4045) #4059 跑一个 release
Generated by Claude Code
- added 4 commits that reference this issue
on Jul 30, 2026 关闭 —— 三项 Proposed 全部落地,剩余两项已各自立项
对照原文的 Proposed
# 内容 落地 1 决定 notify.source#4050 —— 进 ADR-0087 D2 转换层,执行器裸 ??删除2 declared-vs-read ratchet 见下(实际走的比原文三选项更强一档) 3 把「有意 designer-only / read-only」的部分记账,不留隐式豁免 DELIBERATELY_SHALLOW账本、assignment的不可对账豁免、schemaless 类各自的理由,全部带原因写进测试§2 的选项,实际选了哪个
原文列了三条。实测过程中:
- (b) 静态分析被正式划掉 —— 不是 fragile,是不成立:
logic的顶层 config 键就是作者变量名,crud有 5 个additionalProperties: true区域,提取器在这两处会把几乎所有已声明键报成假阳性;而对我们唯一已知的漂移样本(notify.source,藏在resolveSource(cfg, ...)里)本来就会漏报。会漏掉现有唯一样本的 ratchet 比没有更糟。 - 最终形态是 (a) 与 (c) 的前半合并:每个内置节点写一份从读执行器写出的 Zod(不是照
configSchema抄 —— 抄出来对账就是同义反复),再做双向键集比对。(c) 的后半「让执行器parse」被有意推迟,见下。
覆盖范围
每个发布
configSchema的内置节点都已与其执行器双向对过账:PR 覆盖 查出的漂移 #4040 表达式槽(全体 builtin) visibleWhen上线#4064 / #4078 loop / parallel / try_catch 此前零断言;建立形状钉子 + 键集对账 #4161 wait 六个 loose config键 → 声明的waitEventConfig#4210 notify / http / connector_action connector_action 的 schema 根错位(线上表单实际失效) #4228 CRUD 四件 / screen / map 7 个读而未声明 + map.flow别名不发布
configSchema的节点各有记录在案的理由(config-schemas.test.ts)。原文 Scope caveat 点名未查的 8 个节点类型现已全部有结论:
connector_action/loop/map/parallel/try_catch/wait已对账;subflow/script属 schemaless 类 —— 见下面的边界说明。一个被推翻的前提,记录在案
原文(和我的第一条评论)把
loop/parallel/try_catch判为「冗余副本、等着被单一 Zod 源去重、纯删除、最省力」。实测推翻了:生成结果 9–17× 大、深 9 层、全内联无$ref(loop597→5,537 字符 / 25→201 键),要可用就得写投影裁掉九成,那时「单一真相源」是假的(Zod + 投影规则 + 投影校验 = 三样,比现在两样更多)。手写字面量不是副本,是职责不同的第二份产物:表单描述(刻意浅、刻意松)vs 校验契约。剩下的两件,已各自立项
- 3b — wire the flow executors to
parse()their config, and tighten the undeclared-key warning into an error #4277 —— 3b:执行器parse+ 未知键 warn→error。 材料已齐(三个文件覆盖全部扁平节点的执行器派生 Zod),但必须等 feat(automation,formula): warn on undeclared flow-node config keys (#4045) #4059 的警告跑过一个 release。本轮恰好给了这个判断更硬的依据:光 6 个扁平节点就查出 7 个「执行器在读、schema 从未声明」的键 —— 直接收紧成 error 等于拿存量 app 赌一个没测量过的分布。 - The schemaless nodes' designer forms live only in objectui's hand-written table, and nothing reconciles them —
scriptoffers three broken options and cannot author the one that works #4278 —— 覆盖边界外的第一个实例。 本 issue 的对账只覆盖「发布了configSchema的节点」。schemaless 的五个,其表单来自 objectui 手写表,那份表和执行器之间没有任何对账。查了script,是漂的且用户可见:outputVariables(复数)没人读、sms/notification选项运行时必然失败、默认的code是 no-op、而唯一能工作的function/inputs/outputVariable路径表单里根本没有。
顺带
objectui#3082 修掉了同一条缝的另一半:发布的
configSchema会整表替换手写字段组,从而删掉根在connectorConfig/waitEventConfig/boundaryConfig/顶层timeoutMs的 18 个编辑器。connector_action 已经这样丢过一次;wait与boundary_event的整个契约都在兄弟块里,是下一个。
Generated by Claude Code
- (b) 静态分析被正式划掉 —— 不是 fragile,是不成立:
Follow-up to #4027, which closed only the expression subset of that gap (#4040).
What #4040 fixed, and what it didn't
#4027 described two hand-written lists nobody reconciled: a node type's designer
configSchema, and what the rest of the platform does with the keys it declares. #4040 built a ledger + ratchet for the expression slots — everyxExpressionproperty a builtin declares is now validated and cannot silently go unchecked again.The other half is untouched. A
configSchemais written by hand next to an executor that readsconfigby hand, and nothing compares them. A property can be:visibleWhenshape, now covered for expressions only);The concrete instance found while doing #4040
notify's executor readscfg.sourceas a nested{ object, id }shape:Its
configSchemadeclaresrecipients, title, message, channels, topic, severity, sourceObject, sourceId, actorId, actionUrl, payload— nosource. So:sourceworks at run time, with no schema describing it and no test pinning it.cfg.sourceObject ?? src?.objectline is a consumer-side fallback of exactly the kind Prime Directive Add comprehensive test suite for Zod schema validation #12 calls debt — a second de-facto contract next to the declared one. Ifsourceis legacy, it belongs in the ADR-0087 D2 conversion layer (likeflow-node-notify-config-aliasesalready does forto/subject/body/url) so it is declared, tested and removable on a schedule. If it is current, it belongs in theconfigSchema.Either way the current state is the one #4027 was filed about: two lists, no reconciliation, drift invisible until someone trips on it.
Scope caveat — this is one instance, not an audit
I only checked the four executors whose config reads were easy to enumerate (
crud,screen,notify,http) while working on #4040.notify.sourceis what that turned up;crudandhttplooked consistent, and I did not checkconnector_action,loop,map,parallel,try_catch,subflow,waitorscript. The remaining node types have the same structure, so more drift is likely.Two non-findings worth recording so nobody re-chases them:
config.expandappears incrud-nodes.tsonly inside an error-message string (it is a start-node key, andstartis structural with no descriptor), andupdate_record/delete_recordneither declare nor readoutputVariable— consistent, not drift.Proposed
notify.source— conversion-layer alias, or declared property. Not something to guess at; it changes the designer form.configSchema(hand-maintained, but the ratchet keeps it honest — same shape as fix(automation,lint,spec): validate the expression slots a node'sconfigSchemadeclares (#4027) #4040 and feat(runtime): route ledger + conformance guard for the dispatcher↔client surface (#3563) #3569);cfg.<key>/ destructuring in each executor (fragile against indirection, but zero maintenance);configSchemafrom a Zod schema the executor itself parsesconfigwith, so the two cannot diverge by construction. Most correct, most invasive, and the only option that also gets the runtime the type/required/unknown-key enforcementconfigSchemacurrently does not provide (see the corrected TSDoc in fix(automation,lint,spec): validate the expression slots a node'sconfigSchemadeclares (#4027) #4040).log/report what is deliberately designer-only or read-only, rather than leaving the exemption implicit.Related
configSchemaand the executor's wire payload are two unchecked lists — nothing validates flow node config keys at author time #4027 / fix(automation,lint,spec): validate the expression slots a node'sconfigSchemadeclares (#4027) #4040 — the expression subset, closed