Skip to content

Commit 8ab926b

Browse files
os-justinclaude
andauthored
feat(spec)!: ExecutionStepLog.iteration is single-valued (the enclosing loop iteration); the parallel branch index moves to a new optional branch key (#14414) (#15227)
* feat(spec)!: ExecutionStepLog.iteration is single-valued; parallel branch index moves to a new optional branch key Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk * chore(spec): regenerate migrations registry for execution-step-iteration-single-valued Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk * chore(spec): regenerate execution reference and authorable-surface shard for ExecutionStepLog.branch Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 460134a commit 8ab926b

7 files changed

Lines changed: 269 additions & 6 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: `ExecutionStepLog.iteration` is single-valued — the enclosing loop iteration — and the parallel branch index moves to a new optional `branch` key (#14414)
6+
7+
<!-- adr-0087: registered execution-step-iteration-single-valued -->
8+
9+
**BREAKING** for any consumer that read `iteration` as the parallel branch index
10+
on a `regionKind: 'parallel-branch'` step record: that number now means the
11+
enclosing `loop`'s iteration, and the branch index lives on `branch`. Shipped as
12+
`minor` under the repo's launch-window convention for breaking changes; the
13+
hand-migration prescription is registered under protocol major 18. Maintainer
14+
ruling 2026-09-03 on #14414 (director decision batch #15, verbatim 「同意」):
15+
option A.
16+
17+
`ExecutionStepLogSchema.iteration` was declared as "zero-based loop iteration
18+
OR parallel branch index of the enclosing region" — one field, two meanings,
19+
told apart only by reading `regionKind` first. For `loop { body: [ parallel {
20+
branches } ] }` the engine tagged each branch step with the branch index and no
21+
step of that branch with the loop iteration, so a per-row failure inside a
22+
branch was attributable to a branch, never to the row. The sibling `try_catch`
23+
rule (a try/catch region has no index of its own, so it carries the loop
24+
iteration) had deliberately left `parallel` open, because a parallel region
25+
DOES have an index of its own.
26+
27+
**What changes on the record shape** (`packages/spec/src/automation/execution.zod.ts`):
28+
29+
- `iteration` — single-valued: the zero-based iteration of the enclosing
30+
`loop`, carried through any nesting (`try` / `catch` already carried it;
31+
`parallel-branch` now does too). The try/catch sentence is unchanged.
32+
- `branch`**new**, optional, `integer >= 0`: the zero-based index of the
33+
enclosing `parallel` branch, present only on a step inside a parallel
34+
branch. A branch step of a parallel node inside a loop body carries both.
35+
- `regionKind` — unchanged vocabulary; its describe now points the
36+
`parallel-branch` index at `branch`.
37+
38+
**What does NOT change in this PR:** the engine tagger in
39+
`@objectstack/service-automation` still writes the innermost region only —
40+
today it writes the branch index into `iteration` on `parallel-branch` steps
41+
and never writes `branch`. The tagger change is a follow-on card in the same
42+
lane, blocked by this one (three-surface rule: spec first, no engine-only patch
43+
in between). A step record written by an engine that predates that follow-on
44+
therefore carries no `branch` key, and its `iteration` under `parallel-branch`
45+
still holds the legacy branch index.
46+
47+
## FROM → TO
48+
49+
```ts
50+
// before — one key, two meanings; the loop iteration of a branch step is lost
51+
const step = { regionKind: 'parallel-branch', iteration: 1 }; // 1 = branch index
52+
const branchIndex = step.regionKind === 'parallel-branch' ? step.iteration : undefined;
53+
54+
// after — one meaning per key; a branch step inside a loop body carries both
55+
const step = { regionKind: 'parallel-branch', iteration: 3, branch: 1 };
56+
const branchIndex = step.branch; // 1 — the branch
57+
const rowIteration = step.iteration; // 3 — the enclosing loop's row
58+
```
59+
60+
Fix: where a consumer groups or labels `parallel-branch` steps by `iteration`
61+
(the objectui `FlowRunsPanel` grouping key is the one measured reader), read
62+
`branch` for the branch index and keep `iteration` for the row. Reads of
63+
`iteration` on `loop-body`, `try` and `catch` steps need no change.

content/docs/references/automation/execution.mdx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,9 @@ const result = CheckpointSchema.parse(data);
140140
| **error** | `{ code: string; message: string; stack?: string }` | optional | Error details if step failed |
141141
| **retryAttempt** | `integer` | optional | Retry attempt number (0 = first try) |
142142
| **parentNodeId** | `string` | optional | Enclosing structured-region container node ID (loop/parallel/try_catch) |
143-
| **iteration** | `integer` | optional | Zero-based loop iteration or parallel branch index of the enclosing region. A step inside a `try` / `catch` region that is itself inside a loop body carries the enclosing loop's iteration — a try/catch region has no index of its own — while `regionKind` stays `try` / `catch`. |
144-
| **regionKind** | `string` | optional | Region kind the step ran in: loop-body \| parallel-branch \| try \| catch. Stays `try` / `catch` for a step inside a try/catch region nested in a loop body; the loop is reported through `iteration`. |
143+
| **iteration** | `integer` | optional | Zero-based iteration of the enclosing `loop`, carried through any nesting — a step inside a `parallel` branch that is itself inside a loop body carries the loop's iteration here and its branch index on `branch`. A step inside a `try` / `catch` region that is itself inside a loop body carries the enclosing loop's iteration — a try/catch region has no index of its own — while `regionKind` stays `try` / `catch`. |
144+
| **branch** | `integer` | optional | Zero-based index of the enclosing `parallel` branch. Present only on a step inside a parallel branch; absent everywhere else. When the parallel node is itself inside a loop body, the loop iteration is reported through `iteration`, never here. |
145+
| **regionKind** | `string` | optional | Region kind the step ran in: loop-body \| parallel-branch \| try \| catch. Stays `try` / `catch` for a step inside a try/catch region nested in a loop body; the loop is reported through `iteration`. For `parallel-branch` the branch index is reported through `branch`, and the enclosing loop iteration — when the parallel node sits inside a loop body — through `iteration`. |
145146
| **metrics** | `{ selected?: integer; acted?: integer; unmeasuredEffect?: boolean }` | optional | Records this step selected / acted on, as reported by the node executor |
146147
| **skippedBy** | `{ nodeId: string; edgeId?: string; label?: string }` | optional | The gate that closed, when `status` is `skipped` |
147148

@@ -195,8 +196,9 @@ const result = CheckpointSchema.parse(data);
195196
| **error** | `{ code: string; message: string; stack?: string }` | optional | Error details if step failed |
196197
| **retryAttempt** | `integer` | optional | Retry attempt number (0 = first try) |
197198
| **parentNodeId** | `string` | optional | Enclosing structured-region container node ID (loop/parallel/try_catch) |
198-
| **iteration** | `integer` | optional | Zero-based loop iteration or parallel branch index of the enclosing region. A step inside a `try` / `catch` region that is itself inside a loop body carries the enclosing loop's iteration — a try/catch region has no index of its own — while `regionKind` stays `try` / `catch`. |
199-
| **regionKind** | `string` | optional | Region kind the step ran in: loop-body \| parallel-branch \| try \| catch. Stays `try` / `catch` for a step inside a try/catch region nested in a loop body; the loop is reported through `iteration`. |
199+
| **iteration** | `integer` | optional | Zero-based iteration of the enclosing `loop`, carried through any nesting — a step inside a `parallel` branch that is itself inside a loop body carries the loop's iteration here and its branch index on `branch`. A step inside a `try` / `catch` region that is itself inside a loop body carries the enclosing loop's iteration — a try/catch region has no index of its own — while `regionKind` stays `try` / `catch`. |
200+
| **branch** | `integer` | optional | Zero-based index of the enclosing `parallel` branch. Present only on a step inside a parallel branch; absent everywhere else. When the parallel node is itself inside a loop body, the loop iteration is reported through `iteration`, never here. |
201+
| **regionKind** | `string` | optional | Region kind the step ran in: loop-body \| parallel-branch \| try \| catch. Stays `try` / `catch` for a step inside a try/catch region nested in a loop body; the loop is reported through `iteration`. For `parallel-branch` the branch index is reported through `branch`, and the enclosing loop iteration — when the parallel node sits inside a loop body — through `iteration`. |
200202
| **metrics** | `{ selected?: integer; acted?: integer; unmeasuredEffect?: boolean }` | optional | Records this step selected / acted on, as reported by the node executor |
201203
| **skippedBy** | `{ nodeId: string; edgeId?: string; label?: string }` | optional | The gate that closed, when `status` is `skipped` |
202204

packages/spec/authorable-surface/automation.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@
120120
"automation/ExecutionLog:tenantId",
121121
"automation/ExecutionLog:trigger",
122122
"automation/ExecutionLog:variables",
123+
"automation/ExecutionStepLog:branch",
123124
"automation/ExecutionStepLog:completedAt",
124125
"automation/ExecutionStepLog:durationMs",
125126
"automation/ExecutionStepLog:error",

packages/spec/src/automation/execution.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,73 @@ describe('ExecutionStepLogSchema', () => {
114114
expect(step.parentNodeId).toBe('guard');
115115
expect(step.regionKind).toBe('try');
116116
expect(step.iteration).toBe(3);
117+
// Only a `parallel` branch has an index of its own; a try/catch region
118+
// never carries `branch`, so the key is absent — not `0`, not `null`.
119+
expect(step.branch).toBeUndefined();
120+
expect('branch' in step).toBe(false);
121+
});
122+
123+
it('a step inside a parallel branch nested in a loop body carries the loop iteration on `iteration` and the branch index on `branch`', () => {
124+
// `loop { body: [ parallel { branches: [A, B] } ] }` — `iteration` is
125+
// single-valued (the enclosing LOOP's, carried through any nesting) and a
126+
// parallel region, which DOES have an index of its own, reports it on
127+
// `branch`. Both at once is what makes a per-row failure inside a branch
128+
// attributable to its row; under the old one-field-two-meanings declaration
129+
// the branch index displaced the loop iteration and the row was lost.
130+
const step = ExecutionStepLogSchema.parse({
131+
nodeId: 'notify',
132+
nodeType: 'email',
133+
status: 'failure',
134+
startedAt: '2026-01-01T00:00:00Z',
135+
parentNodeId: 'fan_out',
136+
regionKind: 'parallel-branch',
137+
iteration: 3,
138+
branch: 1,
139+
error: { code: 'NODE_FAILURE', message: 'mailbox unavailable' },
140+
});
141+
expect(step.parentNodeId).toBe('fan_out');
142+
expect(step.regionKind).toBe('parallel-branch');
143+
expect(step.iteration).toBe(3);
144+
expect(step.branch).toBe(1);
145+
});
146+
147+
it('`branch: 0` — the first branch — survives the parse as 0', () => {
148+
// Zero is a real index; a falsy-check anywhere on the way would drop it.
149+
const step = ExecutionStepLogSchema.parse({
150+
nodeId: 'notify',
151+
nodeType: 'email',
152+
status: 'success',
153+
startedAt: '2026-01-01T00:00:00Z',
154+
parentNodeId: 'fan_out',
155+
regionKind: 'parallel-branch',
156+
branch: 0,
157+
});
158+
expect(step.branch).toBe(0);
159+
expect(step.iteration).toBeUndefined();
160+
});
161+
162+
it.each([
163+
[-1, 'too_small'],
164+
[1.5, 'invalid_type'],
165+
])('refuses `branch: %j` with one zod issue at path ["branch"] (code %s)', (value, code) => {
166+
// A branch index is a zero-based integer, like `iteration`. The assertion
167+
// is on the issue's PATH and CODE, not on "it threw": a refusal from some
168+
// other key would otherwise pass for this one.
169+
const result = ExecutionStepLogSchema.safeParse({
170+
nodeId: 'notify',
171+
nodeType: 'email',
172+
status: 'success',
173+
startedAt: '2026-01-01T00:00:00Z',
174+
parentNodeId: 'fan_out',
175+
regionKind: 'parallel-branch',
176+
iteration: 0,
177+
branch: value,
178+
});
179+
expect(result.success).toBe(false);
180+
const issues = result.success ? [] : result.error.issues;
181+
expect(issues).toHaveLength(1);
182+
expect(issues[0].path).toEqual(['branch']);
183+
expect(issues[0].code).toBe(code);
117184
});
118185
});
119186

packages/spec/src/automation/execution.zod.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,14 +128,28 @@ export const ExecutionStepLogSchema = lazySchema(() => z.object({
128128
// so run observability can nest per-iteration / per-branch body steps under
129129
// the container instead of showing it as a single opaque step.
130130
parentNodeId: z.string().optional().describe('Enclosing structured-region container node ID (loop/parallel/try_catch)'),
131+
// `iteration` is SINGLE-VALUED: the zero-based iteration of the enclosing
132+
// `loop`, carried through any nesting. It used to double as the parallel
133+
// branch index — one field, two meanings, told apart only by reading
134+
// `regionKind` first — so for `loop { body: [ parallel { branches } ] }`
135+
// every branch step recorded the branch index and no step of that branch
136+
// recorded the loop iteration: a per-row failure inside a branch was
137+
// attributable to a branch, never to the row. The branch index now has its
138+
// own key, `branch`, below (maintainer ruling 2026-09-03, option A: one
139+
// meaning per key; the overload-plus-second-index alternative was not taken).
140+
//
131141
// A `try` / `catch` region has no index of its own, so a step it ran for a
132142
// loop body — `loop { body: [ try_catch { try, catch } ] }`, the containment
133143
// spelling for a per-iteration failure that must not end the sweep — carries
134144
// the ENCLOSING LOOP's iteration while `regionKind` keeps naming the
135145
// try/catch region: the step says which region ran it AND which row it ran
136146
// for. Without that, a caught per-row failure is attributable to no row.
137-
iteration: z.number().int().min(0).optional().describe('Zero-based loop iteration or parallel branch index of the enclosing region. A step inside a `try` / `catch` region that is itself inside a loop body carries the enclosing loop\'s iteration — a try/catch region has no index of its own — while `regionKind` stays `try` / `catch`.'),
138-
regionKind: z.string().optional().describe('Region kind the step ran in: loop-body | parallel-branch | try | catch. Stays `try` / `catch` for a step inside a try/catch region nested in a loop body; the loop is reported through `iteration`.'),
147+
iteration: z.number().int().min(0).optional().describe('Zero-based iteration of the enclosing `loop`, carried through any nesting — a step inside a `parallel` branch that is itself inside a loop body carries the loop\'s iteration here and its branch index on `branch`. A step inside a `try` / `catch` region that is itself inside a loop body carries the enclosing loop\'s iteration — a try/catch region has no index of its own — while `regionKind` stays `try` / `catch`.'),
148+
// A `parallel` region DOES have an index of its own, which is exactly why it
149+
// cannot share `iteration`: a branch step of a parallel node inside a loop
150+
// body carries BOTH — `iteration` for the row, `branch` for the branch.
151+
branch: z.number().int().min(0).optional().describe('Zero-based index of the enclosing `parallel` branch. Present only on a step inside a parallel branch; absent everywhere else. When the parallel node is itself inside a loop body, the loop iteration is reported through `iteration`, never here.'),
152+
regionKind: z.string().optional().describe('Region kind the step ran in: loop-body | parallel-branch | try | catch. Stays `try` / `catch` for a step inside a try/catch region nested in a loop body; the loop is reported through `iteration`. For `parallel-branch` the branch index is reported through `branch`, and the enclosing loop iteration — when the parallel node sits inside a loop body — through `iteration`.'),
139153
// #4354: what the step did to the data, and — for a `skipped` step — which
140154
// gate stopped it. Both feed the run summary aggregated on ExecutionLog.
141155
metrics: ExecutionStepMetricsSchema.optional()
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'execution-step-iteration-single-valued',
7+
surface:
8+
'`ExecutionStepLog.iteration` on a step whose `regionKind` is '
9+
+ '`parallel-branch` — the per-step records under `ExecutionLog.steps`, as '
10+
+ 'the automation run endpoints return them — and the new optional '
11+
+ '`ExecutionStepLog.branch` key',
12+
replacement:
13+
'Read the parallel branch index from `branch`. `iteration` is now '
14+
+ 'single-valued: the zero-based iteration of the enclosing `loop`, carried '
15+
+ 'through any nesting, so a branch step of a `parallel` node that sits '
16+
+ 'inside a loop body carries BOTH keys — `iteration` for the row and '
17+
+ '`branch` for the branch. A consumer that grouped or labelled steps by '
18+
+ '`iteration` under `regionKind: parallel-branch` moves that read to '
19+
+ '`branch`; a consumer reading `iteration` on `loop-body`, `try` or '
20+
+ '`catch` steps changes nothing.',
21+
reason:
22+
'The key was declared as the zero-based loop iteration OR the parallel '
23+
+ 'branch index of the enclosing region — one field, two meanings, told '
24+
+ 'apart only by reading `regionKind` first. The engine tagged each step '
25+
+ 'with its innermost region only, so for a `parallel` node inside a `loop` '
26+
+ 'body every branch step recorded the branch index and no step of that '
27+
+ 'branch recorded the loop iteration: a per-row failure inside a branch '
28+
+ 'was attributable to a branch, never to the row the sweep was processing. '
29+
+ 'The sibling try/catch rule had already settled the containment case — a '
30+
+ 'try/catch region has no index of its own, so it carries the loop '
31+
+ 'iteration — and deliberately left `parallel` open, because there the '
32+
+ 'two indexes genuinely compete for one field. The maintainer ruling of '
33+
+ '2026-09-03 took option A: `iteration` always means the enclosing loop '
34+
+ 'iteration and the branch index moves to its own optional key, so a '
35+
+ 'reader no longer has to branch on `regionKind` to know which number it '
36+
+ 'holds, and getting that wrong no longer silently books a failure against '
37+
+ 'the wrong row. Option B — keep the overload and add a second index whose '
38+
+ 'presence depends on nesting shape — was not taken. This is not a '
39+
+ 'mechanical conversion: a step record written before this change carries '
40+
+ '`iteration` under `parallel-branch` with the branch-index meaning, and '
41+
+ 'only its producer knows whether the parallel node sat inside a loop. The '
42+
+ 'measured corpus held zero `loop { parallel }` nestings and one consumer '
43+
+ 'reading the key — a grouping key in the objectui flow-runs panel — so '
44+
+ 'the migration is a consumer-side read move, not a data rewrite. The '
45+
+ 'engine tagger that writes both keys follows this contract change as its '
46+
+ 'own card; until it lands, `branch` is declared and unwritten, and '
47+
+ '`iteration` on a `parallel-branch` step written by an older engine still '
48+
+ 'holds the branch index.',
49+
acceptanceCriteria:
50+
'No consumer reads `iteration` as a branch index: every read of a '
51+
+ '`parallel-branch` step\'s index goes through `branch`, and every read of '
52+
+ 'the enclosing loop iteration goes through `iteration` regardless of '
53+
+ '`regionKind`. A step record carrying `regionKind: parallel-branch`, '
54+
+ '`iteration: 3`, `branch: 1` parses under `ExecutionStepLogSchema` with '
55+
+ 'both numbers intact, and a negative or fractional `branch` is refused at '
56+
+ 'the `branch` path. A record written before the engine follow-on carries '
57+
+ 'no `branch` key; treat its `iteration` under `parallel-branch` as the '
58+
+ 'legacy branch index only when the record predates the engine build that '
59+
+ 'writes `branch`.',
60+
};

0 commit comments

Comments
 (0)