Observation (measured on origin/main, read-only)
ExecutionStepLogSchema.iteration (packages/spec/src/automation/execution.zod.ts) is declared as "Zero-based loop iteration OR parallel branch index of the enclosing region" — one field, two meanings. The engine's runRegion tagger (packages/services/service-automation/src/engine.ts, the tag() closure) fills parentNodeId / iteration / regionKind only on steps that do not already carry a parentNodeId, so the innermost region wins and the outer one is never recorded:
loop { body: [ parallel { branches: [A, B] } ] } — a step inside branch B of loop iteration 3 is tagged parentNodeId: parallelId, regionKind: 'parallel-branch', iteration: 1 (the branch index). The loop's iteration 3 appears on no step of that branch. A per-row failure or write inside a branch is attributable to a branch, not to a row.
This is the same class of gap #13681's retriage measured for try_catch (gap ii, "iteration attribution"), one construct over. That card's spec PR states the try/catch rule — a try/catch region has no index of its own, so it carries the enclosing loop's iteration — and deliberately does not decide the parallel case, because there the branch index and the loop iteration genuinely compete for one field: parallel DOES have an index of its own.
Why it is a contract question, not an engine patch
Two readings are possible and they shape different records:
- A — keep one field, prefer the loop.
iteration always means the enclosing LOOP's iteration; the branch index moves to a new optional branch key on the step record (additive). Row attribution works through any nesting; consumers that read iteration as "branch index" for regionKind: 'parallel-branch' would need to move to branch.
- B — keep the overload, add the missing outer index as a separate key (e.g.
loopIteration), leaving iteration as declared today. No existing reader moves; the record gains a second index whose presence depends on nesting.
Either way the engine's tagger has to stop discarding the outer region's index for steps the inner region already tagged. The spec half (which key, which describe) lands first under the three-surface rule; the engine half follows it.
Scope note
Context: #13681 (the loop { try_catch } containment card — this finding is not addressed there and #13681 stays open for its own limbs). Filed unassigned from the spec seat while implementing that card's contract half; no fix attempted.
<!-- os-decision-facets -->
① 项目长远合理性(权重 ≥50%):一个字段两种含义,本身就是缺陷形状。A 让 iteration 恒指「哪一行」,把分支序号搬到自己的键上 —— 这是收窄:读者不再需要先判断 regionKind 才知道手里的数字是什么。B 把重载固化成永久契约,再加第二个索引,而且第二个索引的存在与否取决于嵌套 —— 按本轴本义读,这是特例增生。⚠️ 但 A 会动到现有读者,这一点不能被①的方向掩盖。
② 实际业务拉动:⚠️ 未测,而且这是本卡最关键的空白 —— 不知道真实 flow 语料里有几处 loop { parallel } 嵌套,也不知道平台内外有几个消费者在读 iteration。这两个数直接决定 A 的代价是零还是很大。
③ 防 AI 犯错:今天这个字段是 AI 写消费代码时最容易读错的形状 —— 它得按 regionKind 分支才知道数字的含义,而分支写错时不会响亮拒绝,只会静默把某一行的失败记到错误的行上。A 让它读不错;B 要求每个读者永远记住规则,而规则本身依赖嵌套形状。
④ 创业阶段不扩散:两支都 +1 个声明键。差别是 A 用这一个键换来 iteration 单义(净减一个隐含规则),B 用这一个键换来「不动现有读者」(净加一个隐含规则)。
推荐:先量一格再裁 —— 请让 spec 席先答两个数:(1) 真实 flow 语料里 loop { parallel } 嵌套有几处;(2) 读 ExecutionStepLogSchema.iteration 的消费者有几个(平台内 + 已发布客户面)。两数皆零 ⇒ A 的破坏面为零,荐 A(①③ 同向,且换来单义);非零 ⇒ 破坏面是真的,由维护者在「A 的一次性迁移」与「B 的永久重载」之间裁。⛔ 席位不代裁:两支都改已发布记录形状,属协议/公开契约变化,恒人工。
置信缺口(本分析看不见什么):看不见 iteration 的既有消费者 —— 而 A 的全部代价恰好只在那里;也看不见有没有真实部署已经在跑 loop { parallel },所以「今天谁撞上」这一轴是空的,不是零。
Generated by Claude Code
Observation (measured on
origin/main, read-only)ExecutionStepLogSchema.iteration(packages/spec/src/automation/execution.zod.ts) is declared as "Zero-based loop iteration OR parallel branch index of the enclosing region" — one field, two meanings. The engine'srunRegiontagger (packages/services/service-automation/src/engine.ts, thetag()closure) fillsparentNodeId/iteration/regionKindonly on steps that do not already carry aparentNodeId, so the innermost region wins and the outer one is never recorded:loop { body: [ parallel { branches: [A, B] } ] }— a step inside branch B of loop iteration 3 is taggedparentNodeId: parallelId, regionKind: 'parallel-branch', iteration: 1(the branch index). The loop's iteration3appears on no step of that branch. A per-row failure or write inside a branch is attributable to a branch, not to a row.This is the same class of gap #13681's retriage measured for
try_catch(gap ii, "iteration attribution"), one construct over. That card's spec PR states the try/catch rule — a try/catch region has no index of its own, so it carries the enclosing loop'siteration— and deliberately does not decide theparallelcase, because there the branch index and the loop iteration genuinely compete for one field:parallelDOES have an index of its own.Why it is a contract question, not an engine patch
Two readings are possible and they shape different records:
iterationalways means the enclosing LOOP's iteration; the branch index moves to a new optionalbranchkey on the step record (additive). Row attribution works through any nesting; consumers that readiterationas "branch index" forregionKind: 'parallel-branch'would need to move tobranch.loopIteration), leavingiterationas declared today. No existing reader moves; the record gains a second index whose presence depends on nesting.Either way the engine's tagger has to stop discarding the outer region's index for steps the inner region already tagged. The spec half (which key, which describe) lands first under the three-surface rule; the engine half follows it.
Scope note
Context: #13681 (the
loop { try_catch }containment card — this finding is not addressed there and #13681 stays open for its own limbs). Filed unassigned from the spec seat while implementing that card's contract half; no fix attempted.<!-- os-decision-facets -->⚠️ 但 A 会动到现有读者,这一点不能被①的方向掩盖。⚠️ 未测,而且这是本卡最关键的空白 —— 不知道真实 flow 语料里有几处
① 项目长远合理性(权重 ≥50%):一个字段两种含义,本身就是缺陷形状。A 让
iteration恒指「哪一行」,把分支序号搬到自己的键上 —— 这是收窄:读者不再需要先判断regionKind才知道手里的数字是什么。B 把重载固化成永久契约,再加第二个索引,而且第二个索引的存在与否取决于嵌套 —— 按本轴本义读,这是特例增生。② 实际业务拉动:
loop { parallel }嵌套,也不知道平台内外有几个消费者在读iteration。这两个数直接决定 A 的代价是零还是很大。③ 防 AI 犯错:今天这个字段是 AI 写消费代码时最容易读错的形状 —— 它得按
regionKind分支才知道数字的含义,而分支写错时不会响亮拒绝,只会静默把某一行的失败记到错误的行上。A 让它读不错;B 要求每个读者永远记住规则,而规则本身依赖嵌套形状。④ 创业阶段不扩散:两支都 +1 个声明键。差别是 A 用这一个键换来
iteration单义(净减一个隐含规则),B 用这一个键换来「不动现有读者」(净加一个隐含规则)。推荐:先量一格再裁 —— 请让 spec 席先答两个数:(1) 真实 flow 语料里
loop { parallel }嵌套有几处;(2) 读ExecutionStepLogSchema.iteration的消费者有几个(平台内 + 已发布客户面)。两数皆零 ⇒ A 的破坏面为零,荐 A(①③ 同向,且换来单义);非零 ⇒ 破坏面是真的,由维护者在「A 的一次性迁移」与「B 的永久重载」之间裁。⛔ 席位不代裁:两支都改已发布记录形状,属协议/公开契约变化,恒人工。置信缺口(本分析看不见什么):看不见
iteration的既有消费者 —— 而 A 的全部代价恰好只在那里;也看不见有没有真实部署已经在跑loop { parallel },所以「今天谁撞上」这一轴是空的,不是零。Generated by Claude Code