Skip to content

Commit 68d5dfd

Browse files
os-trumpclaude
andauthored
feat(spec): ExecutionStepMetrics gains an optional failures slot; FlowRunSummary.failed is declared as the fold INCLUDING what a delegating node rolled up from its child (#15617, spec half) (#16635)
* wip(spec): ExecutionStepMetrics failure slot + FlowRunSummary.failed fold reconciliation (#15617 spec half) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf * chore(spec): regenerate authorable-surface + docs references for the ExecutionStepMetrics failure slot (#15617) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf * fix(spec): contract review R1.1 on #15617 — narrow the header roll-up clause to the totals that roll, drop the acted analogy at the failed-child boundary, state the mixed case and the third absence arm in the contract text Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf * chore(spec): regenerate docs references for the R1.1 describe wording (#15617) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a0856e3 commit 68d5dfd

5 files changed

Lines changed: 288 additions & 16 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): `ExecutionStepMetrics` gains an optional `failures` slot, and `FlowRunSummary.failed` is declared as the fold INCLUDING what a delegating node rolled up from its child (maintainer ruling 2026-09-06 on #15617, spec half)
6+
7+
Additive. Nothing an author writes is renamed, retired or narrowed; no accept
8+
set shrinks. One optional key is declared on a runtime-produced schema and the
9+
prose of a published contract is reconciled with itself.
10+
11+
**What was wrong.** `FlowRunSummary` said two things about `failed`. Its
12+
header paragraph declared that a `subflow` node rolls its child run's totals
13+
up into the parent — "this summary answers *what did this run cause*" — while
14+
the field itself declared `failed = Σ nodes[].failures`, a fold over the
15+
parent's own node executions. For a parent that delegates its rows to a
16+
`subflow` (or a `map` item) those give different answers, and the engine could
17+
only satisfy the second one: `ExecutionStepMetrics` carried `selected` /
18+
`acted` / `unmeasuredEffect` and no failure slot, so a child's contained
19+
failures had no path into the parent's fold. Measured on the real engine by
20+
the services seat (#15617): parent `loop { subflow(child) }` → parent
21+
`failed=0` while the five child summaries carried `failed=[0,0,0,0,1]`
22+
`acted` rolled up, `failed` did not.
23+
24+
**What this declares.**
25+
26+
- `ExecutionStepMetrics.failures` (optional, integer ≥ 0): node executions
27+
that failed inside a child run this execution delegated to and went on from
28+
— a `subflow` child or a `map` item whose run COMPLETED while containing
29+
failures, i.e. the child's `summary.failed`, rolled up. It folds into the
30+
delegating node's `nodes[].failures` and so into the run-level `failed`
31+
the same fold shape `acted` has, but not the same rule at the failed-child
32+
boundary (next bullet). Absent means the step delegated nothing, or its
33+
child tracked no count, or the producer did not track it (every step the
34+
engine emits between this release and the engine half) — never zero.
35+
- It is NOT the step's own outcome. A step that failed is `status: 'failure'`
36+
and counts once through `nodes[].failures`, as before; a child that FAILED
37+
— whether or not it also contained failures before it failed — is
38+
precisely that step failure: its own `failed`, contained and fatal alike,
39+
stays on the child's run row and nothing rides up, so one failure is never
40+
counted twice. This is where the rule parts from `acted`, which does carry
41+
a failed child's writes up to the parent. The control the card measured (a
42+
failing child → parent `failed=1`) keeps counting exactly as today.
43+
- `FlowRunSummary.failed` is declared, at the field, as the fold of
44+
`nodes[].failures` INCLUDING what a delegating node rolled up; the
45+
`FlowRunNodeSummary.failures` describe names the roll-up path, and its
46+
`status` describe states that a delegating node whose child contained
47+
failures reads `success` beside `failures > 0` — status is judged on the
48+
node's own executions.
49+
50+
**What this does not do yet.** This is the contract half of a two-lane
51+
landing (contract first). No producer populates `failures` in this release:
52+
`subflow-node.ts` and the `map` node roll the child's contained failures into
53+
the slot in the services half, #16314, and only then does a parent's
54+
`failed` start counting them. Until that lands, every `ExecutionStepMetrics`
55+
the engine emits is byte-identical to today's, `failed` is numerically what it
56+
was, and the flow-run reference page keeps the narrowed wording PR #15609
57+
shipped ("node executions **of this run**") on purpose — it is widened when
58+
both halves are in.
59+
60+
**Consumers.** A reader of `ExecutionStepMetrics` sees one more optional
61+
number and nothing else changes shape; a consumer that already sums
62+
`nodes[].failures` to cross-check `failed` keeps agreeing with it, because the
63+
fold is unchanged — the roll-up enters the per-node array, not beside it. Two
64+
consequences of that placement are part of the contract from this release,
65+
even though no producer populates the slot yet: on a delegating node
66+
`nodes[].failures` may exceed `runs` (`runs: 5, failures: 15` is a legal
67+
shape — five subflow executions whose children each contained three), and it
68+
is no longer only that node's own failed executions, so a reader that derived
69+
"this node's executions that failed" or a failure RATE from `failures / runs`
70+
must read a delegating node's number as "failures this node caused, its
71+
child's contained ones included".

content/docs/references/automation/execution.mdx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ const result = CheckpointSchema.parse(data);
144144
| **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`. |
145145
| **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. |
146146
| **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`. |
147-
| **metrics** | `{ selected?: integer; acted?: integer; unmeasuredEffect?: boolean }` | optional | Records this step selected / acted on, as reported by the node executor |
147+
| **metrics** | `{ selected?: integer; acted?: integer; unmeasuredEffect?: boolean; failures?: integer }` | optional | Records this step selected / acted on — and, for a step that delegated to a child run (`subflow`, a `map` item), the failures that child contained — as reported by the node executor |
148148
| **skippedBy** | `{ nodeId: string; edgeId?: string; label?: string }` | optional | The gate that closed, when `status` is `skipped` |
149149

150150
### Nested Shape: `ExecutionLog.summary`
@@ -155,7 +155,7 @@ const result = CheckpointSchema.parse(data);
155155
| **acted** | `integer` || Total records written / effects dispatched by the run |
156156
| **skipped** | `integer` || Total node executions a closed gate prevented |
157157
| **unmeasured** | `integer` | optional | Total executions that may have caused an effect the platform cannot count. Absent = not tracked (an older run), which is not the same as zero. |
158-
| **failed** | `integer` | optional | Total node executions that failed — a fold of `nodes[].failures`. On a run that completed every one of them was contained (caught by a `try_catch` or routed down a `fault` edge) and the run went on. Absent = not tracked (an older run), which is not the same as zero. |
158+
| **failed** | `integer` | optional | Total node executions that failed — a fold of `nodes[].failures`, INCLUDING what a delegating node (`subflow` / `map`) rolled up from a child run that COMPLETED while containing failures: this total answers "what did this run cause", subflows included, so a parent whose child lost rows does not read `failed: 0`. A child that FAILED — whether or not it also contained failures before it failed — counts once, as the delegating step's own failure, and its own `failed` stays on its row (unlike `acted`, which carries a failed child's writes). On a run that completed every one of them was contained (caught by a `try_catch` or routed down a `fault` edge) and the run went on. Absent = not tracked (an older run), which is not the same as zero. |
159159
| **nodes** | `{ nodeId: string; nodeType: string; nodeLabel?: string; status: Enum<'success' \| 'failure' \| 'skipped'>; … }[]` || Per-node breakdown, in first-execution order |
160160
| **gates** | `{ nodeId: string; targetNodeId: string; edgeId?: string; label?: string; … }[]` || Gates that closed during the run, most-skipped first |
161161
| **detailOmitted** | `boolean` | optional | Set when persistence dropped `nodes`/`gates` to keep the stored row bounded — the totals are still exact. Declared so empty arrays are never mistaken for "nothing ran". |
@@ -201,7 +201,7 @@ const result = CheckpointSchema.parse(data);
201201
| **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`. |
202202
| **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. |
203203
| **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`. |
204-
| **metrics** | `{ selected?: integer; acted?: integer; unmeasuredEffect?: boolean }` | optional | Records this step selected / acted on, as reported by the node executor |
204+
| **metrics** | `{ selected?: integer; acted?: integer; unmeasuredEffect?: boolean; failures?: integer }` | optional | Records this step selected / acted on — and, for a step that delegated to a child run (`subflow`, a `map` item), the failures that child contained — as reported by the node executor |
205205
| **skippedBy** | `{ nodeId: string; edgeId?: string; label?: string }` | optional | The gate that closed, when `status` is `skipped` |
206206

207207
### Nested Shape: `ExecutionStepLog.error`
@@ -219,6 +219,7 @@ const result = CheckpointSchema.parse(data);
219219
| **selected** | `integer` | optional | Records this node READ or matched (a `get_record` query, a lookup) |
220220
| **acted** | `integer` | optional | Records this node WROTE (created / updated / deleted) or effects it dispatched (notifications delivered) |
221221
| **unmeasuredEffect** | `boolean` | optional | This execution may have caused an effect the platform cannot count (an external write through a connector). NOT interchangeable with `acted: 0` — it says the count is unknown, not that it is zero. |
222+
| **failures** | `integer` | optional | Node executions that failed inside a child run this execution delegated to and went on from — a `subflow` child or a `map` item whose run COMPLETED while containing failures: its `summary.failed`, rolled up so the parent answers "what did this run cause". Folds into this node's `failures` and so into the run-level `failed`. NOT this execution's own outcome: a step that failed is `status: 'failure'` and counts once through `nodes[].failures`, and a child that FAILED — whether or not it also contained failures before it failed — is exactly that step failure: its own `failed`, contained and fatal alike, stays on the child's run row and nothing rides up here (unlike `acted`, which does carry a failed child's writes). Absent = delegated nothing, or the child tracked no count, or the producer did not track it; never zero. |
222223

223224
### Nested Shape: `ExecutionStepLog.skippedBy`
224225

@@ -240,6 +241,7 @@ const result = CheckpointSchema.parse(data);
240241
| **selected** | `integer` | optional | Records this node READ or matched (a `get_record` query, a lookup) |
241242
| **acted** | `integer` | optional | Records this node WROTE (created / updated / deleted) or effects it dispatched (notifications delivered) |
242243
| **unmeasuredEffect** | `boolean` | optional | This execution may have caused an effect the platform cannot count (an external write through a connector). NOT interchangeable with `acted: 0` — it says the count is unknown, not that it is zero. |
244+
| **failures** | `integer` | optional | Node executions that failed inside a child run this execution delegated to and went on from — a `subflow` child or a `map` item whose run COMPLETED while containing failures: its `summary.failed`, rolled up so the parent answers "what did this run cause". Folds into this node's `failures` and so into the run-level `failed`. NOT this execution's own outcome: a step that failed is `status: 'failure'` and counts once through `nodes[].failures`, and a child that FAILED — whether or not it also contained failures before it failed — is exactly that step failure: its own `failed`, contained and fatal alike, stays on the child's run row and nothing rides up here (unlike `acted`, which does carry a failed child's writes). Absent = delegated nothing, or the child tracked no count, or the producer did not track it; never zero. |
243245

244246

245247
---
@@ -281,9 +283,9 @@ const result = CheckpointSchema.parse(data);
281283
| **nodeId** | `string` || Node ID |
282284
| **nodeType** | `string` || Node action type (e.g., "get_record", "decision") |
283285
| **nodeLabel** | `string` | optional | Human-readable node label |
284-
| **status** | `Enum<'success' \| 'failure' \| 'skipped'>` || Terminal status of the node across the run — `failure` if any execution failed, else `success` if any succeeded, else `skipped` |
286+
| **status** | `Enum<'success' \| 'failure' \| 'skipped'>` || Terminal status of the node across the run — `failure` if any execution failed, else `success` if any succeeded, else `skipped`. Judged on this node's OWN executions: a delegating node (`subflow` / `map`) whose child completed while containing failures reads `success` here with `failures > 0` |
285287
| **runs** | `integer` || Times the node executed (loop iterations and parallel branches each count) |
286-
| **failures** | `integer` || Executions that failed — a failure a `try_catch` caught or a `fault` edge routed counts here too; the run-level `failed` is the sum of this across `nodes` |
288+
| **failures** | `integer` || Executions that failed — a failure a `try_catch` caught or a `fault` edge routed counts here too — plus what a delegating execution rolled up from a child run that COMPLETED (`metrics.failures`: the contained failures of a `subflow` child or a `map` item). On a delegating node this may therefore exceed `runs` and is no longer only this node's own failed executions; a child that FAILED adds only the step's own failure (unlike `acted`, which carries a failed child's writes too); the run-level `failed` is the sum of this across `nodes` |
287289
| **skipped** | `integer` || Times a closed gate kept this node from running at all |
288290
| **selected** | `integer` | optional | Records read across every execution — omitted for a node that reads none |
289291
| **acted** | `integer` | optional | Records written / effects dispatched across every execution — omitted for a node that writes none |
@@ -302,7 +304,7 @@ const result = CheckpointSchema.parse(data);
302304
| **acted** | `integer` || Total records written / effects dispatched by the run |
303305
| **skipped** | `integer` || Total node executions a closed gate prevented |
304306
| **unmeasured** | `integer` | optional | Total executions that may have caused an effect the platform cannot count. Absent = not tracked (an older run), which is not the same as zero. |
305-
| **failed** | `integer` | optional | Total node executions that failed — a fold of `nodes[].failures`. On a run that completed every one of them was contained (caught by a `try_catch` or routed down a `fault` edge) and the run went on. Absent = not tracked (an older run), which is not the same as zero. |
307+
| **failed** | `integer` | optional | Total node executions that failed — a fold of `nodes[].failures`, INCLUDING what a delegating node (`subflow` / `map`) rolled up from a child run that COMPLETED while containing failures: this total answers "what did this run cause", subflows included, so a parent whose child lost rows does not read `failed: 0`. A child that FAILED — whether or not it also contained failures before it failed — counts once, as the delegating step's own failure, and its own `failed` stays on its row (unlike `acted`, which carries a failed child's writes). On a run that completed every one of them was contained (caught by a `try_catch` or routed down a `fault` edge) and the run went on. Absent = not tracked (an older run), which is not the same as zero. |
306308
| **nodes** | `{ nodeId: string; nodeType: string; nodeLabel?: string; status: Enum<'success' \| 'failure' \| 'skipped'>; … }[]` || Per-node breakdown, in first-execution order |
307309
| **gates** | `{ nodeId: string; targetNodeId: string; edgeId?: string; label?: string; … }[]` || Gates that closed during the run, most-skipped first |
308310
| **detailOmitted** | `boolean` | optional | Set when persistence dropped `nodes`/`gates` to keep the stored row bounded — the totals are still exact. Declared so empty arrays are never mistaken for "nothing ran". |
@@ -314,9 +316,9 @@ const result = CheckpointSchema.parse(data);
314316
| **nodeId** | `string` || Node ID |
315317
| **nodeType** | `string` || Node action type (e.g., "get_record", "decision") |
316318
| **nodeLabel** | `string` | optional | Human-readable node label |
317-
| **status** | `Enum<'success' \| 'failure' \| 'skipped'>` || Terminal status of the node across the run — `failure` if any execution failed, else `success` if any succeeded, else `skipped` |
319+
| **status** | `Enum<'success' \| 'failure' \| 'skipped'>` || Terminal status of the node across the run — `failure` if any execution failed, else `success` if any succeeded, else `skipped`. Judged on this node's OWN executions: a delegating node (`subflow` / `map`) whose child completed while containing failures reads `success` here with `failures > 0` |
318320
| **runs** | `integer` || Times the node executed (loop iterations and parallel branches each count) |
319-
| **failures** | `integer` || Executions that failed — a failure a `try_catch` caught or a `fault` edge routed counts here too; the run-level `failed` is the sum of this across `nodes` |
321+
| **failures** | `integer` || Executions that failed — a failure a `try_catch` caught or a `fault` edge routed counts here too — plus what a delegating execution rolled up from a child run that COMPLETED (`metrics.failures`: the contained failures of a `subflow` child or a `map` item). On a delegating node this may therefore exceed `runs` and is no longer only this node's own failed executions; a child that FAILED adds only the step's own failure (unlike `acted`, which carries a failed child's writes too); the run-level `failed` is the sum of this across `nodes` |
320322
| **skipped** | `integer` || Times a closed gate kept this node from running at all |
321323
| **selected** | `integer` | optional | Records read across every execution — omitted for a node that reads none |
322324
| **acted** | `integer` | optional | Records written / effects dispatched across every execution — omitted for a node that writes none |

packages/spec/authorable-surface/automation.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@
141141
"automation/ExecutionStepLog:startedAt",
142142
"automation/ExecutionStepLog:status",
143143
"automation/ExecutionStepMetrics:acted",
144+
"automation/ExecutionStepMetrics:failures",
144145
"automation/ExecutionStepMetrics:selected",
145146
"automation/ExecutionStepMetrics:unmeasuredEffect",
146147
"automation/ExecutionStepSkipReason:edgeId",

0 commit comments

Comments
 (0)