Skip to content

fix(spec,service-automation,lint): flow metadata is canonicalized inside structured regions (#4347) - #4381

Merged
os-zhuang merged 1 commit into
mainfrom
claude/loving-rubin-wd2qq0
Jul 31, 2026
Merged

fix(spec,service-automation,lint): flow metadata is canonicalized inside structured regions (#4347)#4381
os-zhuang merged 1 commit into
mainfrom
claude/loving-rubin-wd2qq0

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #4347.

问题

registerFlow 用三道 pass 归一化存量 flow —— ADR-0087 转换表、FlowSchema.parse、ADR-0032 谓词校验 —— 而这三道都只遍历 flow.nodes / flow.edges。ADR-0031 的容器节点把整张子图放在自己开放的 config 里(loop.config.bodyparallel.config.branches[]try_catch.config.try/.catch),于是三道 pass 都停在容器边界上,元数据变成了随嵌套深度而变

  • 同一个节点,放在顶层会被转换,放进 loop body 就不会;
  • 同一个谓词,在顶层边上存成 { dialect: 'cel', source } 信封,在 loop body 的边上留成裸字符串。

报告方(hotcrm)因此上线了三个"哑火"的 sweep:每次运行都 success: true,查询正确、选中的记录也完全正确,然后什么都不做,日志里也没有任何痕迹 —— 不去断言写入的记录,就无法把"这次没活干"和"这个门根本没打开"区分开。

与 issue 描述的一处出入(已核实)

issue 里那段 repro 在当前 main已经能跑通loop / parallel / try_catch 三个执行器在 #4277 之后都会在运行时用 parseNodeConfig 解析自己的 config,而 LoopConfigSchema.body 就是 FlowRegionSchema,所以 region 边的谓词在执行时会被归一化,门是开的。

真正仍然存在的缺陷是 issue 标题所指的那个:转换表压根到不了 region 里的节点,以及所有"不重新 parse 就读 region"的消费方(Studio 设计器、getFlow、版本历史)看到的仍是未归一化的形状。本 PR 附带的第一个测试就是在 fix 之前失败、fix 之后通过的复现。

改动

packages/specmapFlowNodes 递归进 region

conversions/walk.ts 现在按容器类型 + region 形状双重判定后下探,支持任意深度嵌套(looptry_catchloop)。notice 的 path 会带上 region 位置:

flows[0].nodes[3].config.body.nodes[1].config.filter

这对两条改变行为而非拼写的转换最关键:loop body 里的 webhook / http_request 保留了没有任何执行器认领的 type(整个 run 直接失败);delete_record 保留了 config.filters,导致执行器读的规范键 filter 根本不存在 —— 这正是 flow-node-crud-filter-alias 当初要防的"条件被抹掉"风险。

槽位表声明在 walk.ts 本地(该模块是纯形状 walker,刻意不引入 schema),由一个 reconciliation 测试钉死在 LOOP_NODE_TYPE / PARALLEL_NODE_TYPE / TRY_CATCH_NODE_TYPE 和三个 config schema 的 shape 上,所以新增第四种容器构造时不会悄悄漏掉这里。

packages/spec — 新增 normalizeControlFlowRegions

在 load seam 上、validateControlFlow 之后调用:每个 region 走自己的 schema 解析(递归,因为 region 会嵌套),使嵌套的边和节点与顶层携带同样的规范形状。解析不通过的 region 原样保留 —— 拒绝畸形 region 仍然是 validateControlFlow 的职责,所以"哪些 flow 能注册"没有变化。

packages/spec — 新增 collectFlowGraphs

返回 flow 自身的图 + 每个嵌套 region,各带一个 scope 标签。两个谓词校验器都改成遍历它:引擎的 validateFlowExpressions@objectstack/lintvalidateStackExpressions。于是它们本来要抓的 {record.x} 花括号陷阱(#1491)在 loop body 里也会被抓到,并指明位置:

flow 'sweep' · loop 'sweep_loop' body · edge 'b1' (gate→write) condition

此前这种写法能过 objectstack validate、能过注册,只在运行时炸掉,而那条诊断信息恰好被压掉了。

service-automation — 收紧 legacy {var} 路径(issue 的"次要加固")

evaluateCondition 的模板兜底路径现在拒绝未解析的点号引用,而不是把它当字符串比:

'oppRecord.amount > 500000'  →  'oppRecord.amount' > '500000'  →  'o' > '5'  →  恒为 true

—— 一个报告成功、却从来没有真正拦截过任何东西的门。抛错信息带上源码和修法(换成 CEL 信封,或者本来就想用 {var} 方言的话把引用括起来)。

同时移除了那段 try { … } catch { return false }:块内没有任何会抛的语句(indexOf / slice / Number / compareValues 都是全函数),它什么也没保护,却会把新的拒绝原样吞回成"静默的错误答案" —— 正是 ADR-0032 §1c 为 CEL 路径立下的那条规矩。

只拒绝带点的引用。裸词比较是刻意保留的:'{status} == active' 是有文档的 legacy 写法,替换之后两边都是普通词;小数字面量(1.5)也不是引用。

测试

新增 3 个测试文件(共 32 个用例),每个都是对称性断言 —— 同样的元数据,一份放顶层、一份放 loop body,结果必须一致:

  • packages/spec/src/conversions/region-walk.test.ts — 转换进入 loop / parallel / try_catch,三层嵌套,copy-on-write 保持(未改动的分支保持同一引用),形状门控(http 节点的 config.body 请求体不会被误当作子图),恶意 type: 'constructor' 不会走到 Object.prototype,自引用 region 不会无限递归,以及槽位表的 reconciliation。
  • packages/spec/src/automation/region-normalization.test.ts — 嵌套谓词被信封化并与顶层一致、parallel 分支的 name 不被吞掉、幂等 + copy-on-write、解析失败的 region 原样返回、collectFlowGraphs 的 scope 串联。
  • packages/services/service-automation/src/nested-region-parity.test.ts — 走真正的 registerFlow seam:issue 的 repro(裸字符串与显式信封两种写法都存下信封、都开门、条件为假时仍然不开门),转换后的嵌套 webhook 节点真的被执行(测试里只注册规范 type 的执行器,未转换就会失败),嵌套 CRUD 别名归一化,以及 loop body 里的花括号陷阱现在会被注册期拒绝。
  • packages/lint/src/validate-expressions.test.ts — 作者期(objectstack validate)的对应用例。

验证

gate 结果
pnpm build 71/71 ✅(含 showcase artifact 构建,会跑 lint 校验器)
pnpm test 132/132 ✅
pnpm typecheck 119/119 ✅
pnpm lint
pnpm --filter @objectstack/spec check:generated 8/8 ✅
check:api-surface / check:exported-any ✅(api-surface.json 已重新生成:新增 3 个导出)

刻意不做的事

validateNodeTypesvalidateNodeConfigKeys 仍然只走顶层节点。前者是 soft-fail(warn),后者是 hard-fail —— 让它进 region 有可能拒绝现有 flow,属于独立的行为变更,不该顺手塞进这个 PR。转换现在能到达 region,本 PR 针对的别名场景已经覆盖到了。


Generated by Claude Code

…ide structured regions (#4347)

`registerFlow` canonicalizes a stored flow through three passes — the ADR-0087
conversion table, `FlowSchema.parse`, and the ADR-0032 predicate validation —
and every one of them walked `flow.nodes` / `flow.edges` only. An ADR-0031
container keeps a whole sub-graph in its open `config`, so all three stopped at
the container and metadata came out position-dependent: the same node converted
at the top level and did not one level in, and the same predicate was stored as
a `{ dialect: 'cel', source }` envelope on a top-level edge and left a bare
string on a loop-body edge.

- `mapFlowNodes` recurses into `loop.config.body`, `parallel.config.branches[]`
  and `try_catch.config.try`/`.catch`, to any depth. Notice paths carry the
  region. Matters most for the conversions that change behaviour rather than
  spelling: a `webhook` callout inside a loop body kept a type no executor owns,
  and a `delete_record` kept `config.filters`, leaving the canonical `filter`
  the executor reads absent.
- New `normalizeControlFlowRegions`, called at the load seam after
  `validateControlFlow`: each region parses through its own schema, recursively,
  so nested edges and nodes carry the same canonical shapes as top-level ones. A
  region that does not parse is left untouched, so which flows register is
  unchanged.
- New `collectFlowGraphs` yields a flow's own graph plus every nested region with
  a scope label. Both predicate validators iterate it — the engine's
  `validateFlowExpressions` and lint's `validateStackExpressions` — so the
  `{record.x}` brace-trap they exist to catch is caught inside a region too.
- `evaluateCondition`'s legacy `{var}` path refuses an unresolved dotted
  reference instead of comparing it as a string: `'oppRecord.amount > 500000'`
  compared `'o'` against `'5'` and was constantly true regardless of the amount.
  The `try/catch { return false }` around that block goes with it — nothing in it
  throws, so it guarded nothing and would have swallowed the refusal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HSBbKMdDgHjGpQdrvQUQXj
@vercel

vercel Bot commented Jul 31, 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)
objectstack Ignored Ignored Jul 31, 2026 10:02am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Jul 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/lint, @objectstack/service-automation, @objectstack/spec.

107 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/service-automation, @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/lint, packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-automation, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint, @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/service-automation, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-automation, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/service-automation, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang marked this pull request as ready for review July 31, 2026 10:31
@os-zhuang
os-zhuang merged commit 31e0be9 into main Jul 31, 2026
18 checks passed
@os-zhuang
os-zhuang deleted the claude/loving-rubin-wd2qq0 branch July 31, 2026 10:31
os-zhuang added a commit that referenced this pull request Jul 31, 2026
…4401) (#4408)

A region is a sub-graph inside `FlowNodeSchema.config`, an open `z.record`.
Nothing in the type system says which key on which node type holds one, so every
pass that needs to reach a region node has to be told — and within one week three
of them were told separately, by two changes that were each correct on their own
(#4381 and #4388):

  mapFlowNodes (ADR-0087 conversions)                spec   FLOW_REGION_SLOTS
  validateControlFlow / normalizeControlFlowRegions
    / collectFlowGraphs                              spec   regionSlotsOf
  walkFlowNodes (lint flow rules)                    lint   REGION_SLOTS

Each pinned its own copy with its own reconciliation test. So every copy was
protected from drifting away from the schemas, and nothing would have failed if
the copies drifted from each other — while adding a fourth construct meant
editing three places, and missing one reproduces exactly the silent blind spot
#4347 and #4380 were both filed about.

- New `@objectstack/spec/automation` export `FLOW_REGION_SLOTS` (+ the
  `FLOW_REGION_SLOTS_BY_TYPE` / `FLOW_REGION_CONFIG_KEYS` views) is the only
  statement of the fact. Import-free module, so `spec/conversions/walk.ts` can
  read it and stay the pure shape walker it was written as; mapping a slot onto
  the Zod schema its value parses as stays in `control-flow.zod.ts`.
- The three reconciliation tests collapse into `region-slots.test.ts`, keeping
  the strongest of them (#4388's): it derives each construct's region keys
  BEHAVIOURALLY, by asking the config schema what it accepts in a region shape,
  rather than reading names off `.shape`. It also probes every other exported
  `*ConfigSchema`, so a new region-bearing construct cannot be added without
  either declaring its slots or failing here.

The three walks are deliberately left separate: different inputs (parsed vs raw
authored records), different units (a graph, a node, a copy-on-write rewritten
tree), and the lint one formats human diagnostic trails from node labels —
consumer logic, not protocol (Prime Directive #2).

No behaviour change: every existing test passes unchanged.


Claude-Session: https://claude.ai/code/session_01HSBbKMdDgHjGpQdrvQUQXj

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

applyConversionsToFlow does not recurse into loop bodies — conditions inside a loop are never converted to CEL and the gate silently never opens

2 participants