Skip to content

A designer configSchema and the keys its executor actually reads are still unreconciled — notify honours cfg.source, which no schema declares #4045

Description

@os-zhuang

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 — every xExpression property a builtin declares is now validated and cannot silently go unchecked again.

The other half is untouched. A configSchema is written by hand next to an executor that reads config by hand, and nothing compares them. A property can be:

  • declared but never read — an author fills in a Studio field that does nothing (the visibleWhen shape, now covered for expressions only);
  • read but never declared — the executor honours a key the designer never offers, so it is reachable only by hand-authored metadata.

The concrete instance found while doing #4040

notify's executor reads cfg.source as a nested { object, id } shape:

// packages/services/service-automation/src/builtin/notify-node.ts:92-93
const src = (cfg.source ?? null) as { object?: unknown; id?: unknown } | null;
const object = toStr(interpolate(cfg.sourceObject ?? src?.object, variables, context));

Its configSchema declares recipients, title, message, channels, topic, severity, sourceObject, sourceId, actorId, actionUrl, payload — no source. So:

  1. An author using Studio's generated form can never set the nested shape.
  2. Metadata that does carry source works at run time, with no schema describing it and no test pinning it.
  3. The cfg.sourceObject ?? src?.object line 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. If source is legacy, it belongs in the ADR-0087 D2 conversion layer (like flow-node-notify-config-aliases already does for to/subject/body/url) so it is declared, tested and removable on a schedule. If it is current, it belongs in the configSchema.

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.source is what that turned up; crud and http looked consistent, and I did not check connector_action, loop, map, parallel, try_catch, subflow, wait or script. The remaining node types have the same structure, so more drift is likely.

Two non-findings worth recording so nobody re-chases them: config.expand appears in crud-nodes.ts only inside an error-message string (it is a start-node key, and start is structural with no descriptor), and update_record / delete_record neither declare nor read outputVariable — consistent, not drift.

Proposed

  1. Decide notify.source — conversion-layer alias, or declared property. Not something to guess at; it changes the designer form.
  2. A declared-vs-read ratchet. Harder than the expression one, because "what the executor reads" is not machine-readable today. Options, roughly in increasing cost:
  3. Whichever is chosen, log/report what is deliberately designer-only or read-only, rather than leaving the exemption implicit.

Related

Activity

  1. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    分析两处决策。都先取证再下结论,下面每条主张都有可复现的依据。

    决策一:notify.source —— 进转换层,不进 configSchema

    代码自己已经回答了。 notify-node.ts:80-85:

    Accepts the flat sourceObject/sourceId keys (canonical — mirrors the sys_notification.source_object/source_id columns) or the nested source: { object, id } form (mirrors the messaging emit() 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 shipped loop descriptor 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 个执行器。

    1. notify.source 进转换层(决策一)—— 独立、小、有现成入口,先清掉裸 ??。
    2. 拿 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"的成本。
    3. 未知键先 warn 不 fail。 迁移期必然有存量 flow 带未声明的键(4 个执行器里已发现 notify.source 一处,其余 8 个节点未查)。先 warn 一个 release 收集实际规模,再收紧成 error —— 否则第一个升级的客户就是 registerFlow 全面爆炸。
    4. loop/parallel/try_catch 收尾 —— 纯删重复,最省力,放最后。

    Generated by Claude Code

  2. self-assigned this
    on Jul 30, 2026
  3. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    步骤 B 实测结果 —— 区域型节点不能走「单一 Zod 源」

    #4064 里那张对比表是读源码推的。现在跑了真实生成,在 main 53fbf49 上,选项与 control-flow.test.ts 一致(target: 'draft-2020-12', io: 'input', unrepresentable: 'any')。

    三种可能结局中,是第三种

    我之前列出的三种可能:$defs+$ref 形式、循环引用直接报错、或全内联。结果是全内联:

    今天发布的(手写) 从 Zod 生成的 倍数
    loop 597 字符 · 深度 5 · 25 键 5,537 · 深度 14 · 201 键 9.3× / +9 层 / 8×
    parallel 294 字符 · 深度 6 · 16 键 5,112 · 深度 15 · 189 键 17.4× / +9 层 / 12×
    try_catch 737 字符 · 深度 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

  4. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    阶段小结 —— 已完成的部分、被推翻的前提、以及剩下什么

    这个 issue 上现在有三条我的评论(原始分析、B 的实测数据、以及被 B 推翻的前提)。这条把结论固定下来,免得下一个接手的人(很可能是另一个 agent)重走一遍。

    已完成

    PR 内容
    #4050 notify.source 进 ADR-0087 转换层,执行器那句裸 ?? 删除
    #4059 registerFlow 对未声明的 config 键发警告,带路径与已声明键清单
    #4064 钉住 loop/parallel/try_catch 的表单形状(此前零断言)
    #4078 表单 ↔ Zod 双向键集对账 + DELIBERATELY_SHALLOW ledger

    加上先行的 #4040(表达式 ledger + ratchet),visibleWhen 那一类已经关闭。

    ⚠️ 本 issue 开篇的一个前提是错的

    原文(以及我自己的第一条评论)把 loop/parallel/try_catch 判为「冗余副本,等着被单一 Zod 源去重,纯删除,最省力,放最后」。

    实测推翻了它。 手写字面量不是 Zod 的副本,而是职责不同的第二份产物:表单描述(刻意浅、刻意松)vs 校验契约。生成结果 9–17× 大、深 9 层、全内联无 $ref(loop 597→5,537 字符 / 25→201 键)。要可用就得写投影裁掉九成,那时「单一真相源」是假的 —— Zod + 投影规则 + 投影校验 = 三样,比现在两样更多。

    所以区域型节点走的是选项 1(对账 ratchet),不是选项 3。 已在 #4078 落地。

    剩下的:C 的扁平侧

    screen / CRUD / http / notify 没有 region 键,选项 3 仍然可行。已测清的障碍清单:

    1. 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 }) 可覆盖(已验证)。
    2. objectui 其实不在意:json-schema-to-fields.ts:456 判的是 !== undefined && !== false,{} 照样渲染 keyValue。真正会红的是平台自己的 config-schemas.test.ts(严格 toBe(true))—— 它比它保护的消费者更严,顺手放宽即可。
    3. screen 需要的其余形状全部无损:数组项内的 xExpression、对象值 xRef、format、enum + default、数组项类型(已实测 5/6 通过,第 6 就是上面那条)。

    一条前置建议:扁平侧动的是 screen —— #3528 的现场,且它的 configSchema 直接决定 Studio 里 screen flow 的编辑体验。而 objectui 的渲染没有跨仓库契约测试守着。所以建议配合浏览器验证(仓库有 dogfood-verification skill 正为此准备),而不是纯靠单测推上去。

    剩下的: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

  5. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    收尾 —— 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 + 未知键收紧」作为一个有数据支撑的决定做掉,而不是现在先单独换源再回头改一遍。

    一个副产品:更强的验证判据

    原计划是用浏览器截图比对设计器表单。实际发现有个更强的判据:如果发布出去的 configSchema JSON 逐字节相同,设计器表单就不可能变 —— 那份 JSON 就是设计器的全部输入。所以证明只需一次 JSON diff,浏览器和 objectui 都不必进场,而且它枚举了每一个属性,比一张截图(只覆盖一个视口)更完整。做步骤 2 时可以直接用这个。

    本条线已落地的部分

    PR 内容
    #4040 visibleWhen 上 wire + 表达式 ledger + ratchet
    #4050 notify.source 进转换层,裸 ?? 删除
    #4059 未声明 config 键发警告(含 did-you-mean 与已声明键清单)
    #4064 钉住控制流三节点的设计器表单形状(此前零断言)
    #4078 表单 ↔ Zod 双向键集对账 + DELIBERATELY_SHALLOW ledger

    剩下的两项(C 扁平侧、3b)现在是同一项,且都在等同一份数据。


    Generated by Claude Code

  6. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    测量更新:§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: true xExpression
    connector-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 连接器鉴权、CrudEndpointsConfigSchema REST 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 清单可以收窄

    余下真实规模: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

  7. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    更正上一条的第 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

  8. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    本体落地: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。关键在消费端:objectui FlowNodeInspector 是 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 不是缺口,是这类节点唯一正确的发布姿态。

    剩余


    Generated by Claude Code

  9. os-zhuang commented on Jul 31, 2026

    @os-zhuang
    ContributorAuthor

    关闭 —— 三项 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(loop 597→5,537 字符 / 25→201 键),要可用就得写投影裁掉九成,那时「单一真相源」是假的(Zod + 投影规则 + 投影校验 = 三样,比现在两样更多)。手写字面量不是副本,是职责不同的第二份产物:表单描述(刻意浅、刻意松)vs 校验契约。

    剩下的两件,已各自立项

    1. 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 赌一个没测量过的分布。
    2. The schemaless nodes' designer forms live only in objectui's hand-written table, and nothing reconciles them — script offers 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions